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 12015d0bf WW-5540 Add caching to AbstractLocalizedTextProvider (#1808)
12015d0bf is described below

commit 12015d0bf5ff4683187c16c36d93cd3144553ded
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sun Jul 26 09:52:08 2026 +0200

    WW-5540 Add caching to AbstractLocalizedTextProvider (#1808)
    
    * WW-5540 docs: add caching design spec for AbstractLocalizedTextProvider
    
    Design for caching the class/package hierarchy traversal result in
    findText, keyed on (classloader, class name, textKey, locale). Caches
    the raw resolved pattern (or a NOT_FOUND marker) only; translation and
    formatting stay per-call. Wires invalidation into the existing
    reloadBundles/clearBundle/clearMissingBundlesCache sites.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: add implementation plan and refine spec
    
    Add the 3-task TDD implementation plan and record the
    formatWithNullDetection fall-through decision in the spec.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: deprecate+delegate findMessage/getMessage in plan
    
    Resolve pre-flight duplication/dead-code finding: old traversal helpers
    delegate to the raw twins and are marked @Deprecated instead of being
    duplicated. Add a direct characterization test for the findMessage 
delegator.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 refactor(core): split raw message resolution from formatting
    
    Add getRawMessage/formatMessage and a raw twin findMessageRaw. Re-express
    getMessage via formatMessage and make findMessage delegate to
    findMessageRaw + formatMessage; deprecate both as legacy extension points
    superseded by the raw-resolution path. The deprecated findMessage triggers
    the bundle reload on entry, preserving the reload side effect the old
    getMessage-per-probe walk provided. Groundwork for the traversal caches.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: refine Task 1 plan (deprecate/delegate + reload-on-entry)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 perf(core): cache class-hierarchy text resolution
    
    Cache the class/interface/superclass traversal in findText keyed on
    (classloader, class name, key, locale), storing the raw pattern or a
    NOT_FOUND marker. Formatting stays per call and falls through to the
    next tier when a cached pattern formats to null. Invalidated on
    reloadBundles/clearBundle/clearMissingBundlesCache; reload is hoisted
    to the top of findText so caches are cleared before they are read.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: draft follow-up ticket for null-control-flow cleanup
    
    Capture the deferred result-wrapper refactor (raised during WW-5540) as a
    ready-to-file Jira draft; keep WW-5540 focused on caching.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 perf(core): cache package-hierarchy text resolution
    
    Cache the *.package traversal in findText the same way as the class
    hierarchy, with the same keying, fall-through, and invalidation.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 test(core): tighten localized-text cache tests
    
    Assert single cache entry in the per-call-format tests (proves the raw
    pattern is cached, not the formatted result), and mirror the package-cache
    clearBundle/clearMissingBundlesCache invalidation test.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: note devMode null-valueStack eager-reload edge
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: link follow-up doc to filed ticket WW-5655
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 chore(core): add ASF license header to CacheFixture.properties
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 chore(core): add since/forRemoval to @Deprecated annotations
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: drop follow-up draft superseded by WW-5655
    
    The ticket is filed; the draft's content now lives in WW-5655 itself.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 fix(core): address fresh-eyes review findings
    
    - Document that the deprecated getMessage/findMessage are no longer
      invoked by findText, and name formatMessage as the override point
    - Fall back to the ActionContext-based reloadBundles() when findText is
      called without a value stack, so the RELOADED flag is tracked and the
      caches can warm on that path in reload/devMode
    - Narrow resolveClassHierarchyRaw/resolvePackageHierarchyRaw to
      package-private (the cache key omits indexedKey, which is safe only
      when derived from textKey as the internal call sites do)
    - Suppress java:S2129 on the NOT_FOUND identity sentinel
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs: strip stray NUL bytes from design spec
    
    Two literal NUL bytes in the sentinel example made git/GitHub treat the
    whole markdown file as binary and unreviewable in the PR UI; align the
    example with the shipped sentinel name.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 test(core): cover ModelDriven tier, per-locale keys, indexed keys
    
    Close the review-noted coverage gaps: the ModelDriven tier resolves via
    the shared class-hierarchy cache (action miss + model hit), each locale
    gets its own cache entry backed by a new _de fixture bundle, and indexed
    keys (name[N] -> name[*]) resolve and cache per full textKey.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 fix(core): address Copilot review comments
    
    - Partition the caches by System.identityHashCode of the context
      classloader so a custom ClassLoader overriding hashCode() cannot
      collide or collapse the per-loader partitions
    - Derive the indexed key inside the resolvers (miss-only) instead of
      accepting it as a parameter, so the cache key trivially covers every
      input that influences the resolution result
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 fix(core): resolve SonarCloud quality-gate findings
    
    - Suppress S4973 on isNotFound: the identity comparison against the
      non-interned NOT_FOUND sentinel is the design, not a bug
    - Reduce findMessageRaw cognitive complexity (S3776) by extracting
      getRawMessageWithAlternate, reused by the package walk
    - Add missing @Override annotations and suppress the deliberate
      deprecated-delegator call in the test helper (S1161, S5738)
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 fix(core): make findMessageRaw cycle guard effective
    
    The `checked` set tested `contains(clazz.getName())` but never added the
    class, so the diamond-interface cycle guard was a no-op (a latent issue
    inherited from the original findMessage). Add the class name after the
    contains-check so repeated interface branches aren't re-traversed. Behavior
    is unchanged (lookups are idempotent); this only avoids redundant recursion.
    The deprecated findMessage delegates here, so it's fixed too.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    * WW-5540 docs(core): reference WW-5658 in deprecated method javadoc
    
    Point the @deprecated javadoc of getMessage and findMessage at WW-5658,
    the ticket tracking their removal in the next major release.
    
    Co-Authored-By: Claude Opus 4.8 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../text/AbstractLocalizedTextProvider.java        | 260 +++++-
 .../struts2/text/StrutsLocalizedTextProvider.java  |  61 +-
 .../java/org/apache/struts2/text/CacheFixture.java |  37 +
 .../text/StrutsLocalizedTextProviderTest.java      | 216 +++++
 .../apache/struts2/text/CacheFixture.properties    |  23 +
 .../apache/struts2/text/CacheFixture_de.properties |  19 +
 ...7-23-WW-5540-localized-text-provider-caching.md | 871 +++++++++++++++++++++
 ...-5540-localized-text-provider-caching-design.md | 263 +++++++
 8 files changed, 1680 insertions(+), 70 deletions(-)

diff --git 
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java 
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
index 6185552c1..2873e311e 100644
--- 
a/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
@@ -56,6 +56,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     private static final String TOMCAT_WEBAPP_CLASSLOADER = 
"org.apache.catalina.loader.WebappClassLoader";
     private static final String TOMCAT_WEBAPP_CLASSLOADER_BASE = 
"org.apache.catalina.loader.WebappClassLoaderBase";
     private static final String RELOADED = 
"org.apache.struts2.util.LocalizedTextProvider.reloaded";
+    @SuppressWarnings("java:S2129") // deliberate: a non-interned instance is 
required for an identity (==) sentinel
+    private static final String NOT_FOUND = new 
String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with 
==
 
     protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new 
ConcurrentHashMap<>();
     protected boolean devMode = false;
@@ -66,6 +68,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     private final ConcurrentMap<Integer, List<String>> classLoaderMap = new 
ConcurrentHashMap<>();
     private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
     private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap 
= new ConcurrentHashMap<>();
+    private final ConcurrentMap<TextCacheKey, String> classHierarchyCache = 
new ConcurrentHashMap<>();
+    private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = 
new ConcurrentHashMap<>();
 
     @Override
     public void addDefaultResourceBundle(String bundleName) {
@@ -90,6 +94,22 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         return Thread.currentThread().getContextClassLoader();
     }
 
+    private int currentLoaderHashCode() {
+        // Identity-based on purpose: a custom ClassLoader overriding 
hashCode() must not be able to
+        // collapse (or collide) the per-classloader cache partitions.
+        return System.identityHashCode(getCurrentThreadContextClassLoader());
+    }
+
+    /** Test-support accessor: current number of cached class-hierarchy 
resolutions. */
+    protected int classHierarchyCacheSize() {
+        return classHierarchyCache.size();
+    }
+
+    /** Test-support accessor: current number of cached package-hierarchy 
resolutions. */
+    protected int packageHierarchyCacheSize() {
+        return packageHierarchyCache.size();
+    }
+
     @Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required = 
false)
     public void setCustomI18NResources(String bundles) {
         if (bundles == null || bundles.isEmpty()) {
@@ -187,6 +207,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     protected void clearBundle(final String bundleName, Locale locale) {
         final String key = 
createMissesKey(String.valueOf(getCurrentThreadContextClassLoader().hashCode()),
 bundleName, locale);
         final ResourceBundle removedBundle = bundlesMap.remove(key);
+        classHierarchyCache.clear();
+        packageHierarchyCache.clear();
         LOG.debug("Clearing resource bundle [{}], locale [{}], result: [{}].", 
bundleName, locale, removedBundle != null);
     }
 
@@ -204,6 +226,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
      */
     protected void clearMissingBundlesCache() {
         missingBundles.clear();
+        classHierarchyCache.clear();
+        packageHierarchyCache.clear();
         LOG.debug("Cleared the missing bundles cache.");
     }
 
@@ -222,6 +246,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
                 }
                 if (!reloaded) {
                     bundlesMap.clear();
+                    classHierarchyCache.clear();
+                    packageHierarchyCache.clear();
                     clearResourceBundleClassloaderCaches();
 
                     // now, for the true and utter hack, if we're running in 
tomcat, clear
@@ -507,9 +533,48 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         return result;
     }
 
+    /**
+     * Resolves the raw (untranslated, unformatted) message pattern for a key 
within a single bundle.
+     * Returns {@code null} when the bundle or key is absent. This is the 
cacheable unit relied upon by
+     * the hierarchy-resolution caches; translation and formatting are applied 
separately by
+     * {@link #formatMessage(String, Locale, ValueStack, Object[])}.
+     */
+    private String getRawMessage(String bundleName, Locale locale, String key) 
{
+        ResourceBundle bundle = findResourceBundle(bundleName, locale);
+        if (bundle == null) {
+            return null;
+        }
+        try {
+            return bundle.getString(key);
+        } catch (MissingResourceException e) {
+            LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
+            return null;
+        }
+    }
+
+    /**
+     * Applies value stack variable translation (when a stack is available) 
and {@link MessageFormat}
+     * argument substitution to a raw message pattern. Mirrors the rendering 
previously performed inline
+     * by {@link #getMessage(String, Locale, String, ValueStack, Object[])}.
+     */
+    protected String formatMessage(String rawPattern, Locale locale, 
ValueStack valueStack, Object[] args) {
+        String message = (valueStack != null)
+                ? TextParseUtil.translateVariables(rawPattern, valueStack)
+                : rawPattern;
+        MessageFormat mf = buildMessageFormat(message, locale);
+        return formatWithNullDetection(mf, args);
+    }
+
     /**
      * @return the message from the named resource bundle.
+     * @deprecated since 7.3.0 — superseded by the internal raw-resolution + 
caching path
+     * ({@link #formatMessage(String, Locale, ValueStack, Object[])} over a 
raw lookup). Retained for
+     * backward compatibility with descendant classes that call it directly. 
<strong>No longer invoked
+     * by {@code findText}</strong>: overriding this method does not affect 
framework message lookup
+     * anymore; override {@link #formatMessage(String, Locale, ValueStack, 
Object[])} to customize
+     * rendering instead. Scheduled for removal in the next major release (see 
WW-5658).
      */
+    @Deprecated(since = "7.3.0", forRemoval = true)
     protected String getMessage(String bundleName, Locale locale, String key, 
ValueStack valueStack, Object[] args) {
         ResourceBundle bundle = findResourceBundle(bundleName, locale);
         if (bundle == null) {
@@ -519,12 +584,8 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
             reloadBundles(valueStack.getContext());
         }
         try {
-            String message = bundle.getString(key);
-            if (valueStack != null) {
-                message = 
TextParseUtil.translateVariables(bundle.getString(key), valueStack);
-            }
-            MessageFormat mf = buildMessageFormat(message, locale);
-            return formatWithNullDetection(mf, args);
+            String rawPattern = bundle.getString(key);
+            return formatMessage(rawPattern, locale, valueStack, args);
         } catch (MissingResourceException e) {
             LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
             return null;
@@ -532,73 +593,158 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     }
 
     /**
-     * Traverse up class hierarchy looking for message.  Looks at class, then 
implemented interface,
-     * before going up hierarchy.
-     *
-     * @return the message
+     * Raw-pattern twin of {@link #findMessage}. Walks class, implemented 
interfaces, then up the
+     * hierarchy, returning the first raw message pattern found (via {@link 
#getRawMessage}) without
+     * translation or formatting. Used by the cached class-hierarchy resolver.
      */
-    protected String findMessage(Class<?> clazz, String key, String 
indexedKey, Locale locale, Object[] args, Set<String> checked,
-                                 ValueStack valueStack) {
+    private String findMessageRaw(Class<?> clazz, String key, String 
indexedKey, Locale locale, Set<String> checked) {
         if (checked == null) {
             checked = new TreeSet<>();
         } else if (checked.contains(clazz.getName())) {
             return null;
         }
+        // Record this class so diamond-shaped interface hierarchies aren't 
re-traversed.
+        checked.add(clazz.getName());
 
         // look in properties of this class
-        String msg = getMessage(clazz.getName(), locale, key, valueStack, 
args);
-
+        String msg = getRawMessageWithAlternate(clazz.getName(), locale, key, 
indexedKey);
         if (msg != null) {
             return msg;
         }
 
-        if (indexedKey != null) {
-            msg = getMessage(clazz.getName(), locale, indexedKey, valueStack, 
args);
-
-            if (msg != null) {
-                return msg;
-            }
-        }
-
         // look in properties of implemented interfaces
-        Class<?>[] interfaces = clazz.getInterfaces();
-
-        for (Class<?> anInterface : interfaces) {
-            msg = getMessage(anInterface.getName(), locale, key, valueStack, 
args);
-
+        for (Class<?> anInterface : clazz.getInterfaces()) {
+            msg = getRawMessageWithAlternate(anInterface.getName(), locale, 
key, indexedKey);
             if (msg != null) {
                 return msg;
             }
+        }
 
-            if (indexedKey != null) {
-                msg = getMessage(anInterface.getName(), locale, indexedKey, 
valueStack, args);
-
+        // traverse up hierarchy
+        if (clazz.isInterface()) {
+            for (Class<?> anInterface : clazz.getInterfaces()) {
+                msg = findMessageRaw(anInterface, key, indexedKey, locale, 
checked);
                 if (msg != null) {
                     return msg;
                 }
             }
+        } else if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
+            return findMessageRaw(clazz.getSuperclass(), key, indexedKey, 
locale, checked);
         }
 
-        // traverse up hierarchy
-        if (clazz.isInterface()) {
-            interfaces = clazz.getInterfaces();
+        return null;
+    }
 
-            for (Class<?> anInterface : interfaces) {
-                msg = findMessage(anInterface, key, indexedKey, locale, args, 
checked, valueStack);
+    /**
+     * Resolves the raw message pattern for a key within a single bundle, 
falling back to the
+     * indexed (general-form) key when the primary key is absent.
+     */
+    private String getRawMessageWithAlternate(String bundleName, Locale 
locale, String key, String indexedKey) {
+        String msg = getRawMessage(bundleName, locale, key);
+        if (msg == null && indexedKey != null) {
+            msg = getRawMessage(bundleName, locale, indexedKey);
+        }
+        return msg;
+    }
+
+    /**
+     * Cached resolution of the class/interface/superclass hierarchy for a 
key. Returns the raw pattern
+     * found, or {@link #NOT_FOUND} when the key is absent from the entire 
hierarchy. Keyed on the
+     * context classloader hash + class name + key + locale, so no {@link 
Class} reference is retained.
+     * Uses get + putIfAbsent (never computeIfAbsent) because the 
child-property path recurses into findText.
+     */
+    String resolveClassHierarchyRaw(Class<?> clazz, String textKey, Locale 
locale) {
+        TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), 
clazz.getName(), textKey, locale);
+        String cached = classHierarchyCache.get(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+        // Derived here (miss-only) rather than accepted as a parameter, so 
the cache key trivially
+        // covers every input that influences the resolution result.
+        String raw = findMessageRaw(clazz, textKey, 
extractIndexedName(textKey), locale, null);
+        String toStore = (raw != null) ? raw : NOT_FOUND;
+        classHierarchyCache.putIfAbsent(cacheKey, toStore);
+        return toStore;
+    }
 
+    /** @return true when a cached raw-resolution result represents "not 
found". */
+    @SuppressWarnings("java:S4973") // deliberate identity comparison against 
the non-interned NOT_FOUND sentinel
+    protected boolean isNotFound(String cachedRawResult) {
+        return cachedRawResult == NOT_FOUND;
+    }
+
+    /**
+     * Raw-pattern walk of the {@code *.package} bundles up the class 
hierarchy of {@code startClazz}.
+     * Returns the first raw pattern found (via {@link #getRawMessage}) for 
the key or its indexed form,
+     * or {@code null} when none match.
+     */
+    private String findPackageMessageRaw(Class<?> startClazz, String textKey, 
String indexedTextName, Locale locale) {
+        for (Class<?> clazz = startClazz;
+             (clazz != null) && !clazz.equals(Object.class);
+             clazz = clazz.getSuperclass()) {
+
+            String basePackageName = clazz.getName();
+            while (basePackageName.lastIndexOf('.') != -1) {
+                basePackageName = basePackageName.substring(0, 
basePackageName.lastIndexOf('.'));
+                String packageName = basePackageName + ".package";
+                String msg = getRawMessageWithAlternate(packageName, locale, 
textKey, indexedTextName);
                 if (msg != null) {
                     return msg;
                 }
             }
-        } else {
-            if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
-                return findMessage(clazz.getSuperclass(), key, indexedKey, 
locale, args, checked, valueStack);
-            }
         }
-
         return null;
     }
 
+    /**
+     * Cached resolution of the {@code *.package} hierarchy for a key. Returns 
the raw pattern found, or
+     * {@link #NOT_FOUND} when absent. Same keying and get + putIfAbsent 
discipline as
+     * {@link #resolveClassHierarchyRaw}.
+     */
+    String resolvePackageHierarchyRaw(Class<?> startClazz, String textKey, 
Locale locale) {
+        TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), 
startClazz.getName(), textKey, locale);
+        String cached = packageHierarchyCache.get(cacheKey);
+        if (cached != null) {
+            return cached;
+        }
+        // Derived here (miss-only) rather than accepted as a parameter, so 
the cache key trivially
+        // covers every input that influences the resolution result.
+        String raw = findPackageMessageRaw(startClazz, textKey, 
extractIndexedName(textKey), locale);
+        String toStore = (raw != null) ? raw : NOT_FOUND;
+        packageHierarchyCache.putIfAbsent(cacheKey, toStore);
+        return toStore;
+    }
+
+    /**
+     * Traverse up class hierarchy looking for message.  Looks at class, then 
implemented interface,
+     * before going up hierarchy.
+     *
+     * @return the message
+     * @deprecated since 7.3.0 — superseded by the internal raw-resolution + 
caching path
+     * ({@link #findMessageRaw} + {@link #formatMessage(String, Locale, 
ValueStack, Object[])}). Retained
+     * for backward compatibility with descendant classes that call it 
directly. <strong>No longer
+     * invoked by {@code findText}</strong>: overriding this method does not 
affect framework message
+     * lookup anymore; override {@link #formatMessage(String, Locale, 
ValueStack, Object[])} to
+     * customize rendering instead. Note: unlike the pre-7.3.0 implementation, 
a
+     * candidate whose formatted value is the literal {@code "null"} no longer 
causes the search to
+     * continue deeper in the same hierarchy; this affects only the 
pathological case of the same key
+     * redefined at multiple hierarchy levels with the shallow value 
formatting to {@code "null"}.
+     * The bundle-reload check is now triggered once on entry (when reload 
mode is enabled) rather than
+     * lazily per bundle probe, preserving the reload side effect that the 
previous getMessage-per-probe
+     * walk provided. Scheduled for removal in the next major release (see 
WW-5658).
+     */
+    @Deprecated(since = "7.3.0", forRemoval = true)
+    protected String findMessage(Class<?> clazz, String key, String 
indexedKey, Locale locale, Object[] args, Set<String> checked,
+                                 ValueStack valueStack) {
+        if (valueStack != null) {
+            reloadBundles(valueStack.getContext());
+        } else {
+            reloadBundles();
+        }
+        String rawPattern = findMessageRaw(clazz, key, indexedKey, locale, 
checked);
+        return rawPattern != null ? formatMessage(rawPattern, locale, 
valueStack, args) : null;
+    }
+
     protected String extractIndexedName(String textKey) {
         String indexedTextName = null;
         // calculate indexedTextName (collection[*]) if applicable
@@ -658,6 +804,40 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         }
     }
 
+    static class TextCacheKey {
+        private final int classLoaderHash;
+        private final String className;
+        private final String textKey;
+        private final Locale locale;
+
+        TextCacheKey(int classLoaderHash, String className, String textKey, 
Locale locale) {
+            this.classLoaderHash = classLoaderHash;
+            this.className = className;
+            this.textKey = textKey;
+            this.locale = locale;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            TextCacheKey that = (TextCacheKey) o;
+            return classLoaderHash == that.classLoaderHash
+                    && Objects.equals(className, that.className)
+                    && Objects.equals(textKey, that.textKey)
+                    && Objects.equals(locale, that.locale);
+        }
+
+        @Override
+        public int hashCode() {
+            int result = classLoaderHash;
+            result = 31 * result + (className != null ? className.hashCode() : 
0);
+            result = 31 * result + (textKey != null ? textKey.hashCode() : 0);
+            result = 31 * result + (locale != null ? locale.hashCode() : 0);
+            return result;
+        }
+    }
+
     static class GetDefaultMessageReturnArg {
         String message;
         boolean foundInBundle;
diff --git 
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java 
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
index bfdfe22fb..71340ffe8 100644
--- 
a/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java
@@ -65,6 +65,17 @@ public class StrutsLocalizedTextProvider extends 
AbstractLocalizedTextProvider {
             LOG.debug("Key is null, short-circuit to default message");
             return defaultMessage;
         }
+
+        // Trigger bundle reload (and cache invalidation) once, before any 
cached hierarchy lookup,
+        // so that in reload/devMode the hierarchy caches are cleared before 
they are read. With no
+        // value stack, fall back to the ActionContext-based overload so the 
RELOADED flag is still
+        // tracked and the caches can warm on that path too.
+        if (valueStack != null) {
+            reloadBundles(valueStack.getContext());
+        } else {
+            reloadBundles();
+        }
+
         String indexedTextName = extractIndexedName(textKey);
 
         // Allow for and track an early lookup for the message in the default 
resource bundles first, before searching the class hierarchy.
@@ -81,11 +92,14 @@ public class StrutsLocalizedTextProvider extends 
AbstractLocalizedTextProvider {
             }
         }
 
-        // search up class hierarchy
-        String msg = findMessage(startClazz, textKey, indexedTextName, locale, 
args, null, valueStack);
-
-        if (msg != null) {
-            return msg;
+        // search up class hierarchy (cached raw resolution; format per call)
+        String classHierarchyRaw = resolveClassHierarchyRaw(startClazz, 
textKey, locale);
+        String msg = null;
+        if (!isNotFound(classHierarchyRaw)) {
+            msg = formatMessage(classHierarchyRaw, locale, valueStack, args);
+            if (msg != null) {
+                return msg;
+            }
         }
 
         if (ModelDriven.class.isAssignableFrom(startClazz)) {
@@ -99,37 +113,24 @@ public class StrutsLocalizedTextProvider extends 
AbstractLocalizedTextProvider {
                 if (action instanceof ModelDriven) {
                     Object model = ((ModelDriven<?>) action).getModel();
                     if (model != null) {
-                        msg = findMessage(model.getClass(), textKey, 
indexedTextName, locale, args, null, valueStack);
-                        if (msg != null) {
-                            return msg;
+                        String modelRaw = 
resolveClassHierarchyRaw(model.getClass(), textKey, locale);
+                        if (!isNotFound(modelRaw)) {
+                            msg = formatMessage(modelRaw, locale, valueStack, 
args);
+                            if (msg != null) {
+                                return msg;
+                            }
                         }
                     }
                 }
             }
         }
 
-        // nothing still? alright, search the package hierarchy now
-        for (Class<?> clazz = startClazz;
-             (clazz != null) && !clazz.equals(Object.class);
-             clazz = clazz.getSuperclass()) {
-
-            String basePackageName = clazz.getName();
-            while (basePackageName.lastIndexOf('.') != -1) {
-                basePackageName = basePackageName.substring(0, 
basePackageName.lastIndexOf('.'));
-                String packageName = basePackageName + ".package";
-                msg = getMessage(packageName, locale, textKey, valueStack, 
args);
-
-                if (msg != null) {
-                    return msg;
-                }
-
-                if (indexedTextName != null) {
-                    msg = getMessage(packageName, locale, indexedTextName, 
valueStack, args);
-
-                    if (msg != null) {
-                        return msg;
-                    }
-                }
+        // search the package hierarchy (cached raw resolution; format per 
call)
+        String packageRaw = resolvePackageHierarchyRaw(startClazz, textKey, 
locale);
+        if (!isNotFound(packageRaw)) {
+            msg = formatMessage(packageRaw, locale, valueStack, args);
+            if (msg != null) {
+                return msg;
             }
         }
 
diff --git a/core/src/test/java/org/apache/struts2/text/CacheFixture.java 
b/core/src/test/java/org/apache/struts2/text/CacheFixture.java
new file mode 100644
index 000000000..4e072a570
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/text/CacheFixture.java
@@ -0,0 +1,37 @@
+/*
+ * 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.text;
+
+/**
+ * Simple fixture whose class-associated bundle ({@code 
CacheFixture.properties}) backs the
+ * localized-text caching tests. The {@code name} property is exposed so OGNL 
expressions such as
+ * {@code ${name}} can be resolved against a value stack.
+ */
+public class CacheFixture {
+
+    private final String name;
+
+    public CacheFixture(String name) {
+        this.name = name;
+    }
+
+    public String getName() {
+        return name;
+    }
+}
diff --git 
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
 
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
index 06c84f4e7..d7157c552 100644
--- 
a/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+++ 
b/core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
@@ -547,6 +547,207 @@ public class StrutsLocalizedTextProviderTest extends 
XWorkTestCase {
         assertEquals("Result of bean2.name lookup not as expected ?", "Okay! 
You found Me!", messageResult);
     }
 
+    public void testClassHierarchyCacheReusesFoundPattern() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        assertEquals("Cache not empty before first lookup ?", 0, 
provider.classHierarchyCacheSize());
+        String first = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", first);
+        assertEquals("Cache not populated after found lookup ?", 1, 
provider.classHierarchyCacheSize());
+
+        String second = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Second lookup differs from first ?", first, second);
+        assertEquals("Cache grew on repeated lookup ?", 1, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testClassHierarchyCacheStoresMisses() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        String first = provider.findText(CacheFixture.class, "cache.missing", 
Locale.ENGLISH, "Fallback", null, valueStack);
+        assertEquals("Fallback", first);
+        assertEquals("Miss not cached ?", 1, 
provider.classHierarchyCacheSize());
+
+        String second = provider.findText(CacheFixture.class, "cache.missing", 
Locale.ENGLISH, "Fallback", null, valueStack);
+        assertEquals("Fallback", second);
+        assertEquals("Miss cache grew on repeat ?", 1, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testFormattingIsPerCallNotCached() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        String x = provider.findText(CacheFixture.class, "cache.withparam", 
Locale.ENGLISH, null, new Object[]{"X"}, valueStack);
+        String y = provider.findText(CacheFixture.class, "cache.withparam", 
Locale.ENGLISH, null, new Object[]{"Y"}, valueStack);
+        assertEquals("Value with param X", x);
+        assertEquals("Value with param Y", y);
+        assertEquals("Raw pattern should be cached once, not per format ?", 1, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testOgnlTranslationIsPerCall() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        valueStack.push(new CacheFixture("World"));
+        String world = provider.findText(CacheFixture.class, "cache.withognl", 
Locale.ENGLISH, null, null, valueStack);
+        valueStack.pop();
+        valueStack.push(new CacheFixture("Mars"));
+        String mars = provider.findText(CacheFixture.class, "cache.withognl", 
Locale.ENGLISH, null, null, valueStack);
+        valueStack.pop();
+
+        assertEquals("Hello World", world);
+        assertEquals("Hello Mars", mars);
+        assertEquals("Raw pattern should be cached once across value stacks 
?", 1, provider.classHierarchyCacheSize());
+    }
+
+    public void testNullFormattingFallsThroughToDefault() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // "{0}" with a null arg formats to the literal "null"; findText must 
fall through to the default.
+        String first = provider.findText(CacheFixture.class, 
"cache.nullformat", Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
+        assertEquals("Fallback", first);
+        // Repeat after the pattern is cached — still falls through.
+        String second = provider.findText(CacheFixture.class, 
"cache.nullformat", Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
+        assertEquals("Fallback", second);
+    }
+
+    public void testReloadClearsClassHierarchyCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+        assertEquals("Cache not populated ?", 1, 
provider.classHierarchyCacheSize());
+
+        provider.callReloadBundlesForceReload();
+        assertEquals("Reload did not clear class hierarchy cache ?", 0, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testClearBundleAndClearMissingCacheEmptyClassHierarchyCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+        assertEquals("Cache not populated ?", 1, 
provider.classHierarchyCacheSize());
+        
provider.callClearBundleWithLocale("org/apache/struts2/text/CacheFixture", 
Locale.ENGLISH);
+        assertEquals("clearBundle did not empty class hierarchy cache ?", 0, 
provider.classHierarchyCacheSize());
+
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+        assertEquals("Cache not repopulated ?", 1, 
provider.classHierarchyCacheSize());
+        provider.callClearMissingBundlesCache();
+        assertEquals("clearMissingBundlesCache did not empty class hierarchy 
cache ?", 0, provider.classHierarchyCacheSize());
+    }
+
+    public void testPackageHierarchyCacheReusesFoundPattern() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // ModelDrivenAction2 lives in a package that provides 
"package.properties" = "It works!".
+        assertEquals("Package cache not empty before lookup ?", 0, 
provider.packageHierarchyCacheSize());
+        String first = 
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+        assertEquals("It works!", first);
+        assertEquals("Package cache not populated after found lookup ?", 1, 
provider.packageHierarchyCacheSize());
+
+        String second = 
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+        assertEquals("Second package lookup differs ?", first, second);
+        assertEquals("Package cache grew on repeat ?", 1, 
provider.packageHierarchyCacheSize());
+    }
+
+    public void testReloadClearsPackageHierarchyCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+        assertEquals("Package cache not populated ?", 1, 
provider.packageHierarchyCacheSize());
+
+        provider.callReloadBundlesForceReload();
+        assertEquals("Reload did not clear package hierarchy cache ?", 0, 
provider.packageHierarchyCacheSize());
+    }
+
+    public void 
testClearBundleAndClearMissingCacheEmptyPackageHierarchyCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+        assertEquals("Package cache not populated ?", 1, 
provider.packageHierarchyCacheSize());
+        provider.callClearBundleWithLocale("org/apache/struts2/test/package", 
Locale.getDefault());
+        assertEquals("clearBundle did not empty package hierarchy cache ?", 0, 
provider.packageHierarchyCacheSize());
+
+        provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+        assertEquals("Package cache not repopulated ?", 1, 
provider.packageHierarchyCacheSize());
+        provider.callClearMissingBundlesCache();
+        assertEquals("clearMissingBundlesCache did not empty package hierarchy 
cache ?", 0, provider.packageHierarchyCacheSize());
+    }
+
+    public void testDeprecatedFindMessageStillDelegates() {
+        // findMessage leaves findText's hot path in this task; this locks the 
deprecated delegator.
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        assertEquals("Static cached value", 
provider.callFindMessage(CacheFixture.class, "cache.static", Locale.ENGLISH, 
valueStack));
+        assertNull(provider.callFindMessage(CacheFixture.class, 
"cache.missing", Locale.ENGLISH, valueStack));
+    }
+
+    public void testModelDrivenTierUsesClassHierarchyCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+
+        ModelDrivenAction2 action = new ModelDrivenAction2();
+        Mock mockActionInvocation = new Mock(ActionInvocation.class);
+        mockActionInvocation.matchAndReturn("getAction", action);
+        ActionContext.getContext().withActionInvocation((ActionInvocation) 
mockActionInvocation.proxy());
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // "invalid.count" resolves only via the model's hierarchy (TestBean2 
-> TestBean.properties),
+        // not via the action class hierarchy, so it exercises the ModelDriven 
tier.
+        String first = provider.findText(ModelDrivenAction2.class, 
"invalid.count", Locale.ENGLISH, null, null, valueStack);
+        assertNotNull("Model-tier lookup found nothing ?", first);
+        assertTrue("Model-tier lookup did not resolve via the TestBean bundle 
?", first.startsWith("TestBean model:"));
+        // Two entries: a miss for the action class hierarchy plus a hit for 
the model class hierarchy.
+        assertEquals("Class-hierarchy cache should hold action miss + model 
hit ?", 2, provider.classHierarchyCacheSize());
+
+        String second = provider.findText(ModelDrivenAction2.class, 
"invalid.count", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Warm model-tier lookup differs from cold ?", first, 
second);
+        assertEquals("Cache grew on repeated model-tier lookup ?", 2, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testLocaleIsPartOfCacheKey() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        String english = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        String german = provider.findText(CacheFixture.class, "cache.static", 
Locale.GERMAN, null, null, valueStack);
+        assertEquals("Static cached value", english);
+        assertEquals("Statischer Wert", german);
+        assertEquals("Each locale should have its own cache entry ?", 2, 
provider.classHierarchyCacheSize());
+
+        assertEquals("Warm English lookup differs ?", english,
+                provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack));
+        assertEquals("Warm German lookup differs ?", german,
+                provider.findText(CacheFixture.class, "cache.static", 
Locale.GERMAN, null, null, valueStack));
+        assertEquals("Cache grew on warm per-locale lookups ?", 2, 
provider.classHierarchyCacheSize());
+    }
+
+    public void testIndexedKeyResolvesThroughCache() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // "cache.indexed[20]" falls back to the general form 
"cache.indexed[*]" during raw resolution.
+        String first = provider.findText(CacheFixture.class, 
"cache.indexed[20]", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Indexed cached value", first);
+        assertEquals("Indexed lookup not cached ?", 1, 
provider.classHierarchyCacheSize());
+
+        String second = provider.findText(CacheFixture.class, 
"cache.indexed[20]", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Warm indexed lookup differs from cold ?", first, second);
+        assertEquals("Cache grew on warm indexed lookup ?", 1, 
provider.classHierarchyCacheSize());
+
+        // A different index is a distinct cache key (the cache is keyed on 
the full textKey),
+        // resolving to the same general form.
+        String other = provider.findText(CacheFixture.class, 
"cache.indexed[7]", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Indexed cached value", other);
+        assertEquals("A different index should create its own cache entry ?", 
2, provider.classHierarchyCacheSize());
+    }
+
     @Override
     protected void setUp() throws Exception {
         super.setUp();
@@ -616,5 +817,20 @@ public class StrutsLocalizedTextProviderTest extends 
XWorkTestCase {
             final Object reloadedObject = 
ActionContext.getContext().get(RELOADED);
             return reloadedObject instanceof Boolean && (Boolean) 
reloadedObject;
         }
+
+        @Override
+        public int classHierarchyCacheSize() {
+            return super.classHierarchyCacheSize();
+        }
+
+        @Override
+        public int packageHierarchyCacheSize() {
+            return super.packageHierarchyCacheSize();
+        }
+
+        @SuppressWarnings("removal") // deliberately exercises the deprecated 
delegator
+        public String callFindMessage(Class<?> clazz, String key, Locale 
locale, ValueStack valueStack) {
+            return super.findMessage(clazz, key, null, locale, null, null, 
valueStack);
+        }
     }
 }
diff --git 
a/core/src/test/resources/org/apache/struts2/text/CacheFixture.properties 
b/core/src/test/resources/org/apache/struts2/text/CacheFixture.properties
new file mode 100644
index 000000000..7d19337b3
--- /dev/null
+++ b/core/src/test/resources/org/apache/struts2/text/CacheFixture.properties
@@ -0,0 +1,23 @@
+#
+# 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.
+#
+cache.static=Static cached value
+cache.withparam=Value with param {0}
+cache.withognl=Hello ${name}
+cache.nullformat={0}
+cache.indexed[*]=Indexed cached value
diff --git 
a/core/src/test/resources/org/apache/struts2/text/CacheFixture_de.properties 
b/core/src/test/resources/org/apache/struts2/text/CacheFixture_de.properties
new file mode 100644
index 000000000..a9f49ac84
--- /dev/null
+++ b/core/src/test/resources/org/apache/struts2/text/CacheFixture_de.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+cache.static=Statischer Wert
diff --git 
a/docs/superpowers/plans/2026-07-23-WW-5540-localized-text-provider-caching.md 
b/docs/superpowers/plans/2026-07-23-WW-5540-localized-text-provider-caching.md
new file mode 100644
index 000000000..8d524519e
--- /dev/null
+++ 
b/docs/superpowers/plans/2026-07-23-WW-5540-localized-text-provider-caching.md
@@ -0,0 +1,871 @@
+# WW-5540: LocalizedTextProvider Traversal Caching — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use 
superpowers:subagent-driven-development (recommended) or 
superpowers:executing-plans to implement this plan task-by-task. Steps use 
checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Cache the class- and package-hierarchy traversal in 
`StrutsLocalizedTextProvider.findText` so repeated lookups for the same 
`(classloader, class, key, locale)` collapse to a single map lookup, without 
changing observable behavior.
+
+**Architecture:** Split raw-pattern resolution (cacheable) from 
translation/formatting (per-call). Add two `ConcurrentHashMap` caches in 
`AbstractLocalizedTextProvider` — one for the class/interface/superclass walk, 
one for the `*.package` walk — each storing a raw pattern or a shared 
`NOT_FOUND` sentinel. `findText` reads the caches, then formats per call, and 
falls through to the next tier when a cached pattern formats to `null` 
(preserving `formatWithNullDetection` semantics).
+
+**Tech Stack:** Java, Maven, JUnit 3/4 via `XWorkTestCase` (junit.framework), 
Mockito/mockobjects (existing test deps).
+
+## Global Constraints
+
+- Commit messages MUST be prefixed with the Jira ticket: `WW-5540 
<type>[scope]: <desc>`.
+- Cache key stores the class **name (String)**, never a `Class` object — no 
classloader pinning.
+- Caches are unbounded `ConcurrentHashMap` (consistent with existing 
`bundlesMap`/`missingBundles`).
+- `NOT_FOUND` is a unique `String` instance compared by `==` (identity), never 
by content.
+- Use `get` + `putIfAbsent`, never `computeIfAbsent`, on the new caches (the 
child-property path recurses back into `findText`).
+- Only the raw pattern is cached; `TextParseUtil.translateVariables` + 
`MessageFormat` always run per call.
+- Core tests here extend `XWorkTestCase` — use `testXxx()` methods and 
`junit.framework` `assert*`; **no `@Test`, no AssertJ** (an `@Test` here 
silently never runs).
+- Build/test: `mvn test -DskipAssembly -pl core -Dtest=<Class>#<method>` 
(single) or `-Dtest=<Class>` (whole class).
+- Work on branch `WW-5540-localized-text-provider-caching` (already created 
off `main`).
+
+---
+
+### Task 1: Raw / format split (behavior-preserving refactor)
+
+Extract the "find the raw pattern" and "render a pattern" steps from 
`getMessage`, and add a raw twin of `findMessage`. No caching yet. Behavior 
must be byte-identical; the existing test suite is the safety net.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java`
+- Test (regression only): 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
+
+**Interfaces:**
+- Consumes: existing `findResourceBundle`, `buildMessageFormat`, 
`formatWithNullDetection`, `reloadBundles`.
+- Produces (used by Task 2 & 3):
+  - `private String getRawMessage(String bundleName, Locale locale, String 
key)` → raw pattern or `null`.
+  - `protected String formatMessage(String rawPattern, Locale locale, 
ValueStack valueStack, Object[] args)` → translated+formatted string (or `null` 
via null-detection).
+  - `private String findMessageRaw(Class<?> clazz, String key, String 
indexedKey, Locale locale, Set<String> checked)` → first raw pattern found 
walking class/interface/superclass, or `null`.
+- Deprecates (retained as legacy `protected` extension points, superseded by 
the raw path):
+  - `getMessage(...)` — now `@Deprecated`, delegates its formatting to 
`formatMessage` (behavior identical).
+  - `findMessage(...)` — now `@Deprecated`, delegates to `findMessageRaw` + 
`formatMessage`.
+
+- [ ] **Step 1: Add `getRawMessage` and `formatMessage`, and re-express 
`getMessage` via them (deprecating it)**
+
+In `AbstractLocalizedTextProvider`, add these two methods (place them just 
above the existing `getMessage`):
+
+```java
+/**
+ * Resolves the raw (untranslated, unformatted) message pattern for a key 
within a single bundle.
+ * Returns {@code null} when the bundle or key is absent. This is the 
cacheable unit relied upon by
+ * the hierarchy-resolution caches; translation and formatting are applied 
separately by
+ * {@link #formatMessage(String, Locale, ValueStack, Object[])}.
+ */
+private String getRawMessage(String bundleName, Locale locale, String key) {
+    ResourceBundle bundle = findResourceBundle(bundleName, locale);
+    if (bundle == null) {
+        return null;
+    }
+    try {
+        return bundle.getString(key);
+    } catch (MissingResourceException e) {
+        LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
+        return null;
+    }
+}
+
+/**
+ * Applies value stack variable translation (when a stack is available) and 
{@link MessageFormat}
+ * argument substitution to a raw message pattern. Mirrors the rendering 
previously performed inline
+ * by {@link #getMessage(String, Locale, String, ValueStack, Object[])}.
+ */
+protected String formatMessage(String rawPattern, Locale locale, ValueStack 
valueStack, Object[] args) {
+    String message = (valueStack != null)
+            ? TextParseUtil.translateVariables(rawPattern, valueStack)
+            : rawPattern;
+    MessageFormat mf = buildMessageFormat(message, locale);
+    return formatWithNullDetection(mf, args);
+}
+```
+
+Then replace the body of the existing `getMessage` (currently at 
`AbstractLocalizedTextProvider.java:513-532`) with the version below, and mark 
it `@Deprecated` (it is superseded internally by the raw-resolution path and 
retained only as a legacy extension point):
+
+```java
+/**
+ * @return the message from the named resource bundle.
+ * @deprecated since 7.3.0 — superseded by the internal raw-resolution + 
caching path
+ * ({@link #formatMessage(String, Locale, ValueStack, Object[])} over a raw 
lookup). Retained for
+ * backward compatibility with descendant classes.
+ */
+@Deprecated
+protected String getMessage(String bundleName, Locale locale, String key, 
ValueStack valueStack, Object[] args) {
+    ResourceBundle bundle = findResourceBundle(bundleName, locale);
+    if (bundle == null) {
+        return null;
+    }
+    if (valueStack != null) {
+        reloadBundles(valueStack.getContext());
+    }
+    try {
+        String rawPattern = bundle.getString(key);
+        return formatMessage(rawPattern, locale, valueStack, args);
+    } catch (MissingResourceException e) {
+        LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
+        return null;
+    }
+}
+```
+
+(This keeps `getMessage`'s order — `findResourceBundle` → `reloadBundles` → 
`getString` — identical; only the trailing translate/format is now delegated to 
`formatMessage`, so behavior is unchanged.)
+
+- [ ] **Step 2: Add `findMessageRaw`**
+
+Add next to the existing `findMessage`. It mirrors `findMessage` exactly 
(including the pre-existing, never-populated `checked` cycle guard) but returns 
the raw pattern via `getRawMessage` with no translation/formatting/args:
+
+```java
+/**
+ * Raw-pattern twin of {@link #findMessage}. Walks class, implemented 
interfaces, then up the
+ * hierarchy, returning the first raw message pattern found (via {@link 
#getRawMessage}) without
+ * translation or formatting. Used by the cached class-hierarchy resolver.
+ */
+private String findMessageRaw(Class<?> clazz, String key, String indexedKey, 
Locale locale, Set<String> checked) {
+    if (checked == null) {
+        checked = new TreeSet<>();
+    } else if (checked.contains(clazz.getName())) {
+        return null;
+    }
+
+    // look in properties of this class
+    String msg = getRawMessage(clazz.getName(), locale, key);
+    if (msg != null) {
+        return msg;
+    }
+    if (indexedKey != null) {
+        msg = getRawMessage(clazz.getName(), locale, indexedKey);
+        if (msg != null) {
+            return msg;
+        }
+    }
+
+    // look in properties of implemented interfaces
+    Class<?>[] interfaces = clazz.getInterfaces();
+    for (Class<?> anInterface : interfaces) {
+        msg = getRawMessage(anInterface.getName(), locale, key);
+        if (msg != null) {
+            return msg;
+        }
+        if (indexedKey != null) {
+            msg = getRawMessage(anInterface.getName(), locale, indexedKey);
+            if (msg != null) {
+                return msg;
+            }
+        }
+    }
+
+    // traverse up hierarchy
+    if (clazz.isInterface()) {
+        interfaces = clazz.getInterfaces();
+        for (Class<?> anInterface : interfaces) {
+            msg = findMessageRaw(anInterface, key, indexedKey, locale, 
checked);
+            if (msg != null) {
+                return msg;
+            }
+        }
+    } else {
+        if (!clazz.equals(Object.class) && !clazz.isPrimitive()) {
+            return findMessageRaw(clazz.getSuperclass(), key, indexedKey, 
locale, checked);
+        }
+    }
+
+    return null;
+}
+```
+
+- [ ] **Step 3: Replace `findMessage`'s body with delegation and deprecate it**
+
+Replace the entire body of the existing `findMessage` (currently at 
`AbstractLocalizedTextProvider.java:540-600`) with a thin delegation to 
`findMessageRaw` + `formatMessage`, and mark it `@Deprecated`. This removes the 
duplicated traversal (the walk now lives only in `findMessageRaw`):
+
+```java
+/**
+ * Traverse up class hierarchy looking for message.  Looks at class, then 
implemented interface,
+ * before going up hierarchy.
+ *
+ * @return the message
+ * @deprecated since 7.3.0 — superseded by the internal raw-resolution + 
caching path
+ * ({@link #findMessageRaw} + {@link #formatMessage(String, Locale, 
ValueStack, Object[])}). Retained
+ * for backward compatibility with descendant classes. Note: unlike the 
pre-7.3.0 implementation, a
+ * candidate whose formatted value is the literal {@code "null"} no longer 
causes the search to
+ * continue deeper in the same hierarchy; this affects only the pathological 
case of the same key
+ * redefined at multiple hierarchy levels with the shallow value formatting to 
{@code "null"}.
+ * The bundle-reload check is now triggered once on entry (when reload mode is 
enabled) rather than
+ * lazily per bundle probe, preserving the reload side effect that the 
previous {@code getMessage}-per-probe
+ * walk provided.
+ */
+@Deprecated
+protected String findMessage(Class<?> clazz, String key, String indexedKey, 
Locale locale, Object[] args, Set<String> checked,
+                             ValueStack valueStack) {
+    reloadBundles(valueStack != null ? valueStack.getContext() : null);
+    String rawPattern = findMessageRaw(clazz, key, indexedKey, locale, 
checked);
+    return rawPattern != null ? formatMessage(rawPattern, locale, valueStack, 
args) : null;
+}
+```
+
+(The reload-on-entry preserves the side effect that the old 
`getMessage`-per-probe walk carried, so both the deprecated external-caller 
path and the Task-1 intermediate state — where `findText` still calls 
`findMessage` — keep triggering reload. The final cached path added in Task 2 
relies instead on the reload hoisted to the top of `findText`.)
+
+- [ ] **Step 4: Compile**
+
+Run: `mvn -q test-compile -DskipAssembly -pl core`
+Expected: BUILD SUCCESS (new methods compile; `getRawMessage`/`findMessageRaw` 
may be flagged unused by the IDE but not by the compiler).
+
+- [ ] **Step 5: Run the full regression suite for this class**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest`
+Expected: PASS — all existing tests green. `getMessage` is behavior-identical; 
`findMessage` is behavior-identical for all single-definition keys (the only 
tests here), so the suite proves the split. (`findMessage`'s deprecated 
delegation differs from before only in the pathological multi-level null-format 
case, which no existing test exercises.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java
+git commit -m "WW-5540 refactor(core): split raw message resolution from 
formatting
+
+Add getRawMessage/formatMessage and a raw twin findMessageRaw. Re-express
+getMessage via formatMessage and make findMessage delegate to
+findMessageRaw + formatMessage; deprecate both as legacy extension points
+superseded by the raw-resolution path. Groundwork for the traversal caches.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 2: Class-hierarchy cache (with invalidation, reload hoist, and 
correctness tests)
+
+Introduce the first cache end-to-end: key type, sentinel, map, resolver, 
`findText` wiring for the class + ModelDriven tiers, invalidation at all three 
clear sites, and the reload hoist. Add a test fixture and correctness tests 
first.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java`
+- Modify: 
`core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java`
+- Create: `core/src/test/java/org/apache/struts2/text/CacheFixture.java`
+- Create: 
`core/src/test/resources/org/apache/struts2/text/CacheFixture.properties`
+- Modify: 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
+
+**Interfaces:**
+- Consumes (from Task 1): `getRawMessage`, `formatMessage`, `findMessageRaw`.
+- Produces (used by Task 3 and tests):
+  - `static class TextCacheKey` with fields `int classLoaderHash, String 
className, String textKey, Locale locale` and `equals`/`hashCode`.
+  - `private static final String NOT_FOUND` — identity sentinel.
+  - `private final ConcurrentMap<TextCacheKey, String> classHierarchyCache`.
+  - `private int currentLoaderHashCode()` → 
`getCurrentThreadContextClassLoader().hashCode()`.
+  - `protected String resolveClassHierarchyRaw(Class<?> clazz, String textKey, 
String indexedKey, Locale locale)` → raw pattern or `NOT_FOUND`.
+  - `protected int classHierarchyCacheSize()` → entry count (test support).
+
+- [ ] **Step 1: Create the test fixture class**
+
+Create `core/src/test/java/org/apache/struts2/text/CacheFixture.java`:
+
+```java
+/*
+ * 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.text;
+
+/**
+ * Simple fixture whose class-associated bundle ({@code 
CacheFixture.properties}) backs the
+ * localized-text caching tests. The {@code name} property is exposed so OGNL 
expressions such as
+ * {@code ${name}} can be resolved against a value stack.
+ */
+public class CacheFixture {
+
+    private final String name;
+
+    public CacheFixture(String name) {
+        this.name = name;
+    }
+
+    public String getName() {
+        return name;
+    }
+}
+```
+
+- [ ] **Step 2: Create the fixture bundle**
+
+Create 
`core/src/test/resources/org/apache/struts2/text/CacheFixture.properties`:
+
+```properties
+cache.static=Static cached value
+cache.withparam=Value with param {0}
+cache.withognl=Hello ${name}
+cache.nullformat={0}
+```
+
+- [ ] **Step 3: Add public cache-size accessor to the test helper**
+
+In `StrutsLocalizedTextProviderTest.java`, inside the nested 
`TestStrutsLocalizedTextProvider` class (after 
`getBundlesReloadedIndicatorValue`, before its closing brace), add:
+
+```java
+public int classHierarchyCacheSize() {
+    return super.classHierarchyCacheSize();
+}
+
+public String callFindMessage(Class<?> clazz, String key, Locale locale, 
ValueStack valueStack) {
+    return super.findMessage(clazz, key, null, locale, null, null, valueStack);
+}
+```
+
+- [ ] **Step 4: Write the failing tests**
+
+Add these methods to `StrutsLocalizedTextProviderTest` (anywhere among the 
other `testXxx` methods). They reference 
`resolveClassHierarchyRaw`/`classHierarchyCacheSize`/`NOT_FOUND` behavior that 
does not exist yet, so they fail to compile/pass until Steps 5–9:
+
+```java
+public void testClassHierarchyCacheReusesFoundPattern() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    assertEquals("Cache not empty before first lookup ?", 0, 
provider.classHierarchyCacheSize());
+    String first = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+    assertEquals("Static cached value", first);
+    assertEquals("Cache not populated after found lookup ?", 1, 
provider.classHierarchyCacheSize());
+
+    String second = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+    assertEquals("Second lookup differs from first ?", first, second);
+    assertEquals("Cache grew on repeated lookup ?", 1, 
provider.classHierarchyCacheSize());
+}
+
+public void testClassHierarchyCacheStoresMisses() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    String first = provider.findText(CacheFixture.class, "cache.missing", 
Locale.ENGLISH, "Fallback", null, valueStack);
+    assertEquals("Fallback", first);
+    assertEquals("Miss not cached ?", 1, provider.classHierarchyCacheSize());
+
+    String second = provider.findText(CacheFixture.class, "cache.missing", 
Locale.ENGLISH, "Fallback", null, valueStack);
+    assertEquals("Fallback", second);
+    assertEquals("Miss cache grew on repeat ?", 1, 
provider.classHierarchyCacheSize());
+}
+
+public void testFormattingIsPerCallNotCached() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    String x = provider.findText(CacheFixture.class, "cache.withparam", 
Locale.ENGLISH, null, new Object[]{"X"}, valueStack);
+    String y = provider.findText(CacheFixture.class, "cache.withparam", 
Locale.ENGLISH, null, new Object[]{"Y"}, valueStack);
+    assertEquals("Value with param X", x);
+    assertEquals("Value with param Y", y);
+}
+
+public void testOgnlTranslationIsPerCall() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    valueStack.push(new CacheFixture("World"));
+    String world = provider.findText(CacheFixture.class, "cache.withognl", 
Locale.ENGLISH, null, null, valueStack);
+    valueStack.pop();
+    valueStack.push(new CacheFixture("Mars"));
+    String mars = provider.findText(CacheFixture.class, "cache.withognl", 
Locale.ENGLISH, null, null, valueStack);
+    valueStack.pop();
+
+    assertEquals("Hello World", world);
+    assertEquals("Hello Mars", mars);
+}
+
+public void testNullFormattingFallsThroughToDefault() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    // "{0}" with a null arg formats to the literal "null"; findText must fall 
through to the default.
+    String first = provider.findText(CacheFixture.class, "cache.nullformat", 
Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
+    assertEquals("Fallback", first);
+    // Repeat after the pattern is cached — still falls through.
+    String second = provider.findText(CacheFixture.class, "cache.nullformat", 
Locale.ENGLISH, "Fallback", new Object[]{null}, valueStack);
+    assertEquals("Fallback", second);
+}
+
+public void testReloadClearsClassHierarchyCache() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+    assertEquals("Cache not populated ?", 1, 
provider.classHierarchyCacheSize());
+
+    provider.callReloadBundlesForceReload();
+    assertEquals("Reload did not clear class hierarchy cache ?", 0, 
provider.classHierarchyCacheSize());
+}
+
+public void testClearBundleAndClearMissingCacheEmptyClassHierarchyCache() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+    assertEquals("Cache not populated ?", 1, 
provider.classHierarchyCacheSize());
+    provider.callClearBundleWithLocale("org/apache/struts2/text/CacheFixture", 
Locale.ENGLISH);
+    assertEquals("clearBundle did not empty class hierarchy cache ?", 0, 
provider.classHierarchyCacheSize());
+
+    provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+    assertEquals("Cache not repopulated ?", 1, 
provider.classHierarchyCacheSize());
+    provider.callClearMissingBundlesCache();
+    assertEquals("clearMissingBundlesCache did not empty class hierarchy cache 
?", 0, provider.classHierarchyCacheSize());
+}
+
+public void testDeprecatedFindMessageStillDelegates() {
+    // findMessage leaves findText's hot path in this task; this locks the 
deprecated delegator.
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    assertEquals("Static cached value", 
provider.callFindMessage(CacheFixture.class, "cache.static", Locale.ENGLISH, 
valueStack));
+    assertNull(provider.callFindMessage(CacheFixture.class, "cache.missing", 
Locale.ENGLISH, valueStack));
+}
+```
+
+- [ ] **Step 5: Run the new tests to confirm they fail**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testClassHierarchyCacheReusesFoundPattern+testReloadClearsClassHierarchyCache`
+Expected: compilation failure (`classHierarchyCacheSize()` undefined) — this 
is the red state.
+
+- [ ] **Step 6: Add the cache field, sentinel, key type, loader-hash helper, 
and size accessor**
+
+In `AbstractLocalizedTextProvider`, add the sentinel near the other constants 
(after `RELOADED` at line ~58):
+
+```java
+private static final String NOT_FOUND = new 
String("__STRUTS_TEXT_NOT_FOUND__"); // unique identity sentinel; compared with 
==
+```
+
+Add the field next to the other caches (after `delegatedClassLoaderMap` at 
line ~68):
+
+```java
+private final ConcurrentMap<TextCacheKey, String> classHierarchyCache = new 
ConcurrentHashMap<>();
+```
+
+Add the helper and the size accessor (place near 
`getCurrentThreadContextClassLoader`):
+
+```java
+private int currentLoaderHashCode() {
+    return getCurrentThreadContextClassLoader().hashCode();
+}
+
+/** Test-support accessor: current number of cached class-hierarchy 
resolutions. */
+protected int classHierarchyCacheSize() {
+    return classHierarchyCache.size();
+}
+```
+
+Add the key class next to the existing `MessageFormatKey` static class:
+
+```java
+static class TextCacheKey {
+    private final int classLoaderHash;
+    private final String className;
+    private final String textKey;
+    private final Locale locale;
+
+    TextCacheKey(int classLoaderHash, String className, String textKey, Locale 
locale) {
+        this.classLoaderHash = classLoaderHash;
+        this.className = className;
+        this.textKey = textKey;
+        this.locale = locale;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        TextCacheKey that = (TextCacheKey) o;
+        return classLoaderHash == that.classLoaderHash
+                && Objects.equals(className, that.className)
+                && Objects.equals(textKey, that.textKey)
+                && Objects.equals(locale, that.locale);
+    }
+
+    @Override
+    public int hashCode() {
+        int result = classLoaderHash;
+        result = 31 * result + (className != null ? className.hashCode() : 0);
+        result = 31 * result + (textKey != null ? textKey.hashCode() : 0);
+        result = 31 * result + (locale != null ? locale.hashCode() : 0);
+        return result;
+    }
+}
+```
+
+(`Objects`, `ConcurrentMap`, `ConcurrentHashMap`, `Locale` are already 
imported.)
+
+- [ ] **Step 7: Add the cached class-hierarchy resolver**
+
+Add to `AbstractLocalizedTextProvider` (near `findMessageRaw`):
+
+```java
+/**
+ * Cached resolution of the class/interface/superclass hierarchy for a key. 
Returns the raw pattern
+ * found, or {@link #NOT_FOUND} when the key is absent from the entire 
hierarchy. Keyed on the
+ * context classloader hash + class name + key + locale, so no {@link Class} 
reference is retained.
+ * Uses get + putIfAbsent (never computeIfAbsent) because the child-property 
path recurses into findText.
+ */
+protected String resolveClassHierarchyRaw(Class<?> clazz, String textKey, 
String indexedKey, Locale locale) {
+    TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), 
clazz.getName(), textKey, locale);
+    String cached = classHierarchyCache.get(cacheKey);
+    if (cached != null) {
+        return cached;
+    }
+    String raw = findMessageRaw(clazz, textKey, indexedKey, locale, null);
+    String toStore = (raw != null) ? raw : NOT_FOUND;
+    classHierarchyCache.putIfAbsent(cacheKey, toStore);
+    return toStore;
+}
+
+/** @return true when a cached raw-resolution result represents "not found". */
+protected boolean isNotFound(String cachedRawResult) {
+    return cachedRawResult == NOT_FOUND;
+}
+```
+
+(`NOT_FOUND` is `private` and not visible to the `StrutsLocalizedTextProvider` 
subclass, so the subclass tests "found?" via `isNotFound(...)` rather than 
referencing the sentinel directly.)
+
+- [ ] **Step 8: Wire invalidation into the three clear sites**
+
+In `reloadBundles(Map<String, Object> context)`, inside the `if (!reloaded)` 
block, add the clear immediately after `bundlesMap.clear();` (line ~224):
+
+```java
+bundlesMap.clear();
+classHierarchyCache.clear();
+```
+
+In `clearBundle(String bundleName, Locale locale)` (line ~187), add after the 
`bundlesMap.remove(key)` line:
+
+```java
+final ResourceBundle removedBundle = bundlesMap.remove(key);
+classHierarchyCache.clear();
+```
+
+In `clearMissingBundlesCache()` (line ~205), add after 
`missingBundles.clear();`:
+
+```java
+missingBundles.clear();
+classHierarchyCache.clear();
+```
+
+- [ ] **Step 9: Rewire `findText` (class + ModelDriven tiers) and hoist the 
reload**
+
+In `StrutsLocalizedTextProvider.findText(Class<?>, String, Locale, String, 
Object[], ValueStack)` (lines 62-195), make these edits.
+
+Immediately after the `textKey == null` guard (after line 67), add the reload 
hoist:
+
+```java
+        // Trigger bundle reload (and cache invalidation) once, before any 
cached hierarchy lookup,
+        // so that in reload/devMode the hierarchy caches are cleared before 
they are read.
+        reloadBundles(valueStack != null ? valueStack.getContext() : null);
+```
+
+Replace the class-hierarchy block (current lines 84-89):
+
+```java
+        // search up class hierarchy
+        String msg = findMessage(startClazz, textKey, indexedTextName, locale, 
args, null, valueStack);
+
+        if (msg != null) {
+            return msg;
+        }
+```
+
+with:
+
+```java
+        // search up class hierarchy (cached raw resolution; format per call)
+        String classHierarchyRaw = resolveClassHierarchyRaw(startClazz, 
textKey, indexedTextName, locale);
+        String msg = null;
+        if (!isNotFound(classHierarchyRaw)) {
+            msg = formatMessage(classHierarchyRaw, locale, valueStack, args);
+            if (msg != null) {
+                return msg;
+            }
+        }
+```
+
+Replace the ModelDriven inner lookup (current lines 102-105):
+
+```java
+                        msg = findMessage(model.getClass(), textKey, 
indexedTextName, locale, args, null, valueStack);
+                        if (msg != null) {
+                            return msg;
+                        }
+```
+
+with:
+
+```java
+                        String modelRaw = 
resolveClassHierarchyRaw(model.getClass(), textKey, indexedTextName, locale);
+                        if (!isNotFound(modelRaw)) {
+                            msg = formatMessage(modelRaw, locale, valueStack, 
args);
+                            if (msg != null) {
+                                return msg;
+                            }
+                        }
+```
+
+(`isNotFound` was added in Step 7; the sentinel itself stays private to 
`AbstractLocalizedTextProvider`.)
+
+Leave the package loop (lines 111-134), child-property block, and 
default-message block unchanged in this task.
+
+- [ ] **Step 10: Compile and run the new tests**
+
+Run: `mvn -q test-compile -DskipAssembly -pl core`
+Expected: BUILD SUCCESS.
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testClassHierarchyCacheReusesFoundPattern+testClassHierarchyCacheStoresMisses+testFormattingIsPerCallNotCached+testOgnlTranslationIsPerCall+testNullFormattingFallsThroughToDefault+testReloadClearsClassHierarchyCache+testClearBundleAndClearMissingCacheEmptyClassHierarchyCache+testDeprecatedFindMessageStillDelegates`
+Expected: PASS (8 tests green).
+
+- [ ] **Step 11: Run the full class to confirm no regression**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest`
+Expected: PASS — all tests (existing + 6 new) green.
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java \
+        
core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java \
+        core/src/test/java/org/apache/struts2/text/CacheFixture.java \
+        
core/src/test/resources/org/apache/struts2/text/CacheFixture.properties \
+        
core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+git commit -m "WW-5540 perf(core): cache class-hierarchy text resolution
+
+Cache the class/interface/superclass traversal in findText keyed on
+(classloader, class name, key, locale), storing the raw pattern or a
+NOT_FOUND marker. Formatting stays per call and falls through to the
+next tier when a cached pattern formats to null. Invalidated on
+reloadBundles/clearBundle/clearMissingBundlesCache; reload is hoisted
+to the top of findText so caches are cleared before they are read.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+### Task 3: Package-hierarchy cache
+
+Cache the `*.package` traversal the same way, wire it into `findText`, and 
invalidate it at the same three sites.
+
+**Files:**
+- Modify: 
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java`
+- Modify: 
`core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java`
+- Modify: 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
+
+**Interfaces:**
+- Consumes (from Task 1/2): `getRawMessage`, `formatMessage`, `isNotFound`, 
`TextCacheKey`, `NOT_FOUND`, `currentLoaderHashCode`.
+- Produces:
+  - `private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache`.
+  - `private String findPackageMessageRaw(Class<?> startClazz, String textKey, 
String indexedTextName, Locale locale)` → first raw `*.package` match or `null`.
+  - `protected String resolvePackageHierarchyRaw(Class<?> startClazz, String 
textKey, String indexedTextName, Locale locale)` → raw pattern or `NOT_FOUND`.
+  - `protected int packageHierarchyCacheSize()` → entry count (test support).
+
+- [ ] **Step 1: Add public accessor to the test helper**
+
+In `StrutsLocalizedTextProviderTest.TestStrutsLocalizedTextProvider`, add:
+
+```java
+public int packageHierarchyCacheSize() {
+    return super.packageHierarchyCacheSize();
+}
+```
+
+- [ ] **Step 2: Write the failing tests**
+
+Add to `StrutsLocalizedTextProviderTest`:
+
+```java
+public void testPackageHierarchyCacheReusesFoundPattern() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    // ModelDrivenAction2 lives in a package that provides 
"package.properties" = "It works!".
+    assertEquals("Package cache not empty before lookup ?", 0, 
provider.packageHierarchyCacheSize());
+    String first = 
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+    assertEquals("It works!", first);
+    assertEquals("Package cache not populated after found lookup ?", 1, 
provider.packageHierarchyCacheSize());
+
+    String second = 
provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+    assertEquals("Second package lookup differs ?", first, second);
+    assertEquals("Package cache grew on repeat ?", 1, 
provider.packageHierarchyCacheSize());
+}
+
+public void testReloadClearsPackageHierarchyCache() {
+    TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+    ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+    provider.findText(org.apache.struts2.test.ModelDrivenAction2.class, 
"package.properties", Locale.getDefault(), null, null, valueStack);
+    assertEquals("Package cache not populated ?", 1, 
provider.packageHierarchyCacheSize());
+
+    provider.callReloadBundlesForceReload();
+    assertEquals("Reload did not clear package hierarchy cache ?", 0, 
provider.packageHierarchyCacheSize());
+}
+```
+
+- [ ] **Step 3: Run the new tests to confirm they fail**
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testPackageHierarchyCacheReusesFoundPattern+testReloadClearsPackageHierarchyCache`
+Expected: compilation failure (`packageHierarchyCacheSize()` undefined) — red 
state.
+
+- [ ] **Step 4: Add the package cache field, raw walk, resolver, and size 
accessor**
+
+In `AbstractLocalizedTextProvider`, add the field next to 
`classHierarchyCache`:
+
+```java
+private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = new 
ConcurrentHashMap<>();
+```
+
+Add the size accessor next to `classHierarchyCacheSize`:
+
+```java
+/** Test-support accessor: current number of cached package-hierarchy 
resolutions. */
+protected int packageHierarchyCacheSize() {
+    return packageHierarchyCache.size();
+}
+```
+
+Add the raw package walk (mirrors the current package loop in 
`StrutsLocalizedTextProvider.findText`, using `getRawMessage`) and its cached 
resolver, next to `resolveClassHierarchyRaw`:
+
+```java
+/**
+ * Raw-pattern walk of the {@code *.package} bundles up the class hierarchy of 
{@code startClazz}.
+ * Returns the first raw pattern found (via {@link #getRawMessage}) for the 
key or its indexed form,
+ * or {@code null} when none match.
+ */
+private String findPackageMessageRaw(Class<?> startClazz, String textKey, 
String indexedTextName, Locale locale) {
+    for (Class<?> clazz = startClazz;
+         (clazz != null) && !clazz.equals(Object.class);
+         clazz = clazz.getSuperclass()) {
+
+        String basePackageName = clazz.getName();
+        while (basePackageName.lastIndexOf('.') != -1) {
+            basePackageName = basePackageName.substring(0, 
basePackageName.lastIndexOf('.'));
+            String packageName = basePackageName + ".package";
+            String msg = getRawMessage(packageName, locale, textKey);
+            if (msg != null) {
+                return msg;
+            }
+            if (indexedTextName != null) {
+                msg = getRawMessage(packageName, locale, indexedTextName);
+                if (msg != null) {
+                    return msg;
+                }
+            }
+        }
+    }
+    return null;
+}
+
+/**
+ * Cached resolution of the {@code *.package} hierarchy for a key. Returns the 
raw pattern found, or
+ * {@link #NOT_FOUND} when absent. Same keying and get + putIfAbsent 
discipline as
+ * {@link #resolveClassHierarchyRaw}.
+ */
+protected String resolvePackageHierarchyRaw(Class<?> startClazz, String 
textKey, String indexedTextName, Locale locale) {
+    TextCacheKey cacheKey = new TextCacheKey(currentLoaderHashCode(), 
startClazz.getName(), textKey, locale);
+    String cached = packageHierarchyCache.get(cacheKey);
+    if (cached != null) {
+        return cached;
+    }
+    String raw = findPackageMessageRaw(startClazz, textKey, indexedTextName, 
locale);
+    String toStore = (raw != null) ? raw : NOT_FOUND;
+    packageHierarchyCache.putIfAbsent(cacheKey, toStore);
+    return toStore;
+}
+```
+
+- [ ] **Step 5: Invalidate the package cache at the three clear sites**
+
+Add `packageHierarchyCache.clear();` immediately after each 
`classHierarchyCache.clear();` added in Task 2 — in `reloadBundles` (the `if 
(!reloaded)` block), `clearBundle`, and `clearMissingBundlesCache`.
+
+- [ ] **Step 6: Replace the package loop in `findText` with the cached 
resolver**
+
+In `StrutsLocalizedTextProvider.findText`, replace the entire 
package-hierarchy loop (current lines 111-134):
+
+```java
+        // nothing still? alright, search the package hierarchy now
+        for (Class<?> clazz = startClazz;
+             (clazz != null) && !clazz.equals(Object.class);
+             clazz = clazz.getSuperclass()) {
+
+            String basePackageName = clazz.getName();
+            while (basePackageName.lastIndexOf('.') != -1) {
+                basePackageName = basePackageName.substring(0, 
basePackageName.lastIndexOf('.'));
+                String packageName = basePackageName + ".package";
+                msg = getMessage(packageName, locale, textKey, valueStack, 
args);
+
+                if (msg != null) {
+                    return msg;
+                }
+
+                if (indexedTextName != null) {
+                    msg = getMessage(packageName, locale, indexedTextName, 
valueStack, args);
+
+                    if (msg != null) {
+                        return msg;
+                    }
+                }
+            }
+        }
+```
+
+with:
+
+```java
+        // search the package hierarchy (cached raw resolution; format per 
call)
+        String packageRaw = resolvePackageHierarchyRaw(startClazz, textKey, 
indexedTextName, locale);
+        if (!isNotFound(packageRaw)) {
+            msg = formatMessage(packageRaw, locale, valueStack, args);
+            if (msg != null) {
+                return msg;
+            }
+        }
+```
+
+- [ ] **Step 7: Compile and run the new tests**
+
+Run: `mvn -q test-compile -DskipAssembly -pl core`
+Expected: BUILD SUCCESS.
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=StrutsLocalizedTextProviderTest#testPackageHierarchyCacheReusesFoundPattern+testReloadClearsPackageHierarchyCache`
+Expected: PASS (2 tests green). In particular `testFindTextInPackage` 
(existing) must still pass.
+
+- [ ] **Step 8: Run the full class and the sibling suite**
+
+Run: `mvn test -DskipAssembly -pl core -Dtest=StrutsLocalizedTextProviderTest`
+Expected: PASS — all tests green.
+
+Run: `mvn test -DskipAssembly -pl core 
-Dtest=GlobalLocalizedTextProviderTest,LocalizedTextUtilTest`
+Expected: PASS (or "No tests matching" for any class that doesn't exist — 
confirm the ones that do exist pass). This guards the other 
`AbstractLocalizedTextProvider` subclass and legacy util.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add 
core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java \
+        
core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java \
+        
core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java
+git commit -m "WW-5540 perf(core): cache package-hierarchy text resolution
+
+Cache the *.package traversal in findText the same way as the class
+hierarchy, with the same keying, fall-through, and invalidation.
+
+Co-Authored-By: Claude Opus 4.8 <[email protected]>"
+```
+
+---
+
+## Final verification
+
+- [ ] Run the whole core text package and a broad i18n-touching slice:
+  `mvn test -DskipAssembly -pl core 
-Dtest='*LocalizedText*,*TextProvider*,TextProviderSupportTest'`
+  Expected: PASS.
+- [ ] Confirm no `computeIfAbsent` was used on the new caches and no `Class` 
object is stored in any key.
+- [ ] Confirm `NOT_FOUND` is only ever compared via `isNotFound(...)` / `==`, 
never `.equals`.
+
+## Notes / residual behavior (documented in the spec)
+
+- A message that formats to the literal `"null"` and is redefined deeper in 
the *same* class hierarchy may resolve differently than before (shallow `null` 
short-circuits the cached path). Accepted as pathological — see the spec's 
"formatWithNullDetection fall-through" section.
+- Caches are unbounded, consistent with `bundlesMap`/`missingBundles`; see the 
spec's "Known limitation".
diff --git 
a/docs/superpowers/specs/2026-07-23-WW-5540-localized-text-provider-caching-design.md
 
b/docs/superpowers/specs/2026-07-23-WW-5540-localized-text-provider-caching-design.md
new file mode 100644
index 000000000..9b01de70a
--- /dev/null
+++ 
b/docs/superpowers/specs/2026-07-23-WW-5540-localized-text-provider-caching-design.md
@@ -0,0 +1,263 @@
+# WW-5540: Add caching to AbstractLocalizedTextProvider
+
+- **Jira:** [WW-5540](https://issues.apache.org/jira/browse/WW-5540) — 
Improvement (Minor)
+- **Fix version:** 7.3.0
+- **Component:** Core
+- **Affected classes:** 
`org.apache.struts2.text.AbstractLocalizedTextProvider`, 
`org.apache.struts2.text.StrutsLocalizedTextProvider`
+
+## Problem
+
+`StrutsLocalizedTextProvider.findText(Class, textKey, locale, defaultMessage, 
args, valueStack)`
+runs a full **class → interface → superclass → `*.package` hierarchy traversal 
on every
+call**, plus an optional child-property recursion. This method is on the 
hottest path in the
+framework: it backs every UI tag label and every validation message rendered 
during a request.
+
+Three caches already exist in `AbstractLocalizedTextProvider`:
+
+- `bundlesMap` — resolved `ResourceBundle` by `(classloader, bundle, locale)`
+- `messageFormats` — `MessageFormat` by `(pattern, locale)`
+- `missingBundles` — negative cache for missing *bundles*
+
+None of them caches the **outcome of the hierarchy traversal** keyed on
+`(classloader, class, textKey, locale)`. Missing keys are especially costly: 
they walk the
+entire class hierarchy *and* the entire package hierarchy, each iteration 
performing a bundle
+lookup and (for a miss) triggering a swallowed `MissingResourceException`. 
This work repeats
+identically on every request.
+
+## Goal
+
+Cache the result of the traversal so repeated lookups for the same `(class, 
key, locale)`
+collapse to a single map lookup, for both found and not-found keys — **without 
changing any
+observable behavior** (lookup order, dynamic-message evaluation, argument 
formatting,
+devMode/reload semantics).
+
+## Non-goals
+
+- No caching of the final rendered string (would break OGNL/`${...}` and 
per-call `args`).
+- No new `StrutsConstant` toggle (consistent with the other, un-toggled 
caches).
+- No bounded/LRU eviction — the caches remain unbounded `ConcurrentHashMap`s, 
matching
+  `bundlesMap` / `missingBundles`. See *Known limitation* below.
+- No JMH microbenchmark added to the repo.
+
+## Core invariant
+
+The return value of `findText` is **not** a pure function of `(class, key, 
locale)`:
+
+- The raw message may contain `${...}` OGNL expressions, evaluated per call via
+  `TextParseUtil.translateVariables(pattern, valueStack)`.
+- `MessageFormat` is applied with per-call `args`.
+- The `defaultMessage`, the `ModelDriven` model instance, and the 
child-property recursion
+  all depend on runtime `ActionContext` / `ValueStack` state.
+
+Therefore **only the raw resolved pattern (or a `NOT_FOUND` marker) is 
cached.** Translation
+and formatting always run per call.
+
+### `formatWithNullDetection` fall-through
+
+`formatWithNullDetection` returns `null` when a message formats to the literal 
string `"null"`,
+and the current `findText` treats that as "not found in this tier" and **keeps 
searching** the
+remaining tiers (ModelDriven → package → default). Because formatting depends 
on per-call `args`
+/ ValueStack, the raw pattern the hierarchy "resolves to" is not a pure 
function of
+`(class, key, locale)` in that case (e.g. a `"{0}"` pattern with a `null` 
argument formats to
+`"null"`).
+
+**Decision:** keep positive + negative caching, and preserve the fall-through: 
after a positive
+cache hit, `findText` formats the cached raw pattern and, **only if the 
formatted result is
+non-null, returns it** — otherwise it falls through to the next tier exactly 
as today. This makes
+the common case (shallow match formats to `null`, fall through to 
package/default) behave
+identically.
+
+**Documented residual difference:** if the *same* key is defined at multiple 
levels of a single
+class hierarchy and the shallowest match formats to `"null"` for the given 
args while a deeper
+match would format to a real value, the current code returns the deeper match, 
whereas the cached
+path returns the shallow `null` and falls through past the deeper match. This 
requires the same
+key redefined within one hierarchy with the shallow value formatting to 
exactly `"null"` — treated
+as pathological and accepted.
+
+## Design (Approach B — hierarchy-result cache)
+
+### 1. Raw / format split
+
+Add a private helper to `AbstractLocalizedTextProvider` that renders a raw 
pattern exactly as
+`getMessage` does inline today:
+
+```java
+private String formatMessage(String rawPattern, Locale locale, ValueStack 
valueStack, Object[] args) {
+    String message = (valueStack != null)
+        ? TextParseUtil.translateVariables(rawPattern, valueStack)
+        : rawPattern;
+    MessageFormat mf = buildMessageFormat(message, locale);   // already 
cached via messageFormats
+    return formatWithNullDetection(mf, args);
+}
+```
+
+Add a raw resolver that does only lookup, no rendering:
+
+```java
+private String getRawMessage(String bundleName, Locale locale, String key) {
+    ResourceBundle bundle = findResourceBundle(bundleName, locale);   // 
already cached via bundlesMap
+    if (bundle == null) return null;
+    try {
+        return bundle.getString(key);
+    } catch (MissingResourceException e) {
+        LOG.debug("Missing key [{}] in bundle [{}]!", key, bundleName);
+        return null;
+    }
+}
+```
+
+`getMessage(bundleName, locale, key, valueStack, args)` keeps its current 
signature and
+behavior, re-expressed as `getRawMessage(...)` followed by 
`formatMessage(...)` so every
+existing caller is byte-for-byte equivalent.
+
+### 2. Two caches
+
+```java
+private final ConcurrentMap<TextCacheKey, String> classHierarchyCache   = new 
ConcurrentHashMap<>();
+private final ConcurrentMap<TextCacheKey, String> packageHierarchyCache = new 
ConcurrentHashMap<>();
+
+private static final String NOT_FOUND = new 
String("__STRUTS_TEXT_NOT_FOUND__"); // identity sentinel
+```
+
+- Values are either the raw pattern or the shared `NOT_FOUND` sentinel (a 
distinct `String`
+  instance compared by `==`). A sentinel is required because 
`ConcurrentHashMap` forbids null
+  values.
+- `TextCacheKey` is a small static class modelled on the existing 
`MessageFormatKey`, holding
+  **`int classLoaderHash`, `String className`, `String textKey`, `Locale 
locale`** with
+  `equals`/`hashCode`.
+- The key stores the **class name (String), not the `Class` object**, so the 
cache never pins
+  a `Class`/classloader (consistent with recent classloader-leak remediation). 
`classLoaderHash`
+  uses `getCurrentThreadContextClassLoader().hashCode()`, matching the keying 
scheme already
+  used by `bundlesMap` / `missingBundles` / `classLoaderMap`.
+
+### 3. Cached resolution methods
+
+```java
+private String resolveClassHierarchyRaw(Class<?> clazz, String textKey, String 
indexedKey, Locale locale) {
+    TextCacheKey key = new TextCacheKey(loaderHash(), clazz.getName(), 
textKey, locale);
+    String cached = classHierarchyCache.get(key);
+    if (cached != null) return cached;                     // hit: pattern or 
NOT_FOUND
+    String raw = findMessageRaw(clazz, textKey, indexedKey, locale, null);
+    String toStore = (raw != null) ? raw : NOT_FOUND;
+    classHierarchyCache.putIfAbsent(key, toStore);
+    return toStore;
+}
+```
+
+- `resolvePackageHierarchyRaw(...)` mirrors this around the `*.package` loop.
+- `findMessageRaw` is the raw twin of `findMessage`: walks class → interfaces 
→ superclass,
+  tries `textKey` then `indexedKey` per class/interface, returns the raw 
pattern via
+  `getRawMessage` (no translate/format/args). The internal recursion stays 
uncached; only the
+  top-level entry per `(class, key, locale)` is cached.
+- Uses **`get` + `putIfAbsent`, not `computeIfAbsent`** — the child-property 
path recurses back
+  into `findText`, and a nested `computeIfAbsent` on the same map during its 
own mapping
+  function is unsafe.
+
+### 4. `findText` flow (StrutsLocalizedTextProvider)
+
+Order is **identical to the current implementation**; only the traversal steps 
become cached:
+
+```
+if (textKey == null) return defaultMessage;
+indexedTextName = extractIndexedName(textKey);
+
+if (searchDefaultBundlesFirst) { ... unchanged early lookup ... }
+
+// class/interface/superclass hierarchy — CACHED
+raw = resolveClassHierarchyRaw(startClazz, textKey, indexedTextName, locale);
+if (raw != NOT_FOUND) { msg = formatMessage(raw, locale, valueStack, args); if 
(msg != null) return msg; }
+
+// ModelDriven model hierarchy — CACHED (reuses classHierarchyCache)
+if (ModelDriven.class.isAssignableFrom(startClazz)) {
+    ... resolve model ...
+    raw = resolveClassHierarchyRaw(model.getClass(), textKey, indexedTextName, 
locale);
+    if (raw != NOT_FOUND) { msg = formatMessage(raw, locale, valueStack, 
args); if (msg != null) return msg; }
+}
+
+// *.package hierarchy — CACHED
+raw = resolvePackageHierarchyRaw(startClazz, textKey, indexedTextName, locale);
+if (raw != NOT_FOUND) { msg = formatMessage(raw, locale, valueStack, args); if 
(msg != null) return msg; }
+
+// child-property recursion — UNCACHED (valueStack-dependent) — unchanged
+// default message — unchanged
+```
+
+Keeping `classHierarchyCache` and `packageHierarchyCache` separate preserves 
the exact
+ordering (class → ModelDriven → package → child-property → default). The 
`ModelDriven` model
+lookup reuses the class-hierarchy cache for free.
+
+## Cache invalidation
+
+The two new caches are cleared at every site that already clears the bundle 
caches:
+
+1. **`reloadBundles(context)`** — inside the guarded block that calls 
`bundlesMap.clear()`,
+   also `classHierarchyCache.clear()` and `packageHierarchyCache.clear()`. 
This preserves
+   devMode / `struts.i18n.reload=true` semantics: the caches are wiped once 
per request
+   (guarded by the `RELOADED` context flag), so edited properties are always 
seen. In
+   production (`reloadBundles=false`) the caches persist — the intended win. 
When `findText`
+   is called with a null ValueStack, the hoisted reload now passes a null 
context; in
+   reload/devMode this triggers an eager cache/bundle clear that the previous 
per-probe path
+   skipped for null-stack lookups — strictly more eager (never staler), and 
inert in
+   production where reload is disabled.
+2. **`clearBundle(bundleName, locale)`** — clear both new caches fully (a 
cached pattern may
+   originate from the cleared bundle; rare admin operation, full clear 
acceptable).
+3. **`clearMissingBundlesCache()`** — clear both new caches fully, honoring 
its documented
+   intent that previously-missing lookups may now resolve (our `NOT_FOUND` 
entries are
+   analogous to the missing-bundle cache).
+
+**Not invalidated by `addDefaultResourceBundle`:** the two caches only ever 
hold class-named
+and `*.package` bundle results. Default bundles are consulted solely in the 
uncached
+`getDefaultMessage` path, so adding a default bundle cannot stale these caches.
+
+## Correctness invariants preserved
+
+- **Dynamic `${...}` messages** — raw pattern cached; `translateVariables` 
runs per call against
+  the live `ValueStack`, so different stacks yield different outputs.
+- **Parameterized messages** — `MessageFormat.format(args)` runs per call.
+- **`defaultMessage`, child-property, `ModelDriven` model resolution** — 
runtime-dependent,
+  never cached at the result level.
+- **Lookup order** — unchanged.
+- **No classloader pinning** — keys hold class names, not `Class` objects.
+
+## Testing
+
+Tests live in 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`,
+which extends `XWorkTestCase` — **JUnit 3/4 style: `testXxx()` methods with 
plain `assert*`
+(junit.framework), no `@Test`, no AssertJ.** (A `@Test`-annotated method here 
would silently
+never run.)
+
+1. **Found key, repeated lookup** — same message on both calls; second served 
from cache.
+2. **Missing key, repeated lookup** — default/null on both; cached as 
`NOT_FOUND` (verified
+   indirectly: after caching a miss, adding the property + `reloadBundles` 
makes it resolve).
+3. **Dynamic `${...}` message after a cache hit** — same `(class, key, 
locale)` rendered against
+   two different `ValueStack`s yields two different results.
+4. **Parameterized message** — different `args` across calls format correctly.
+5. **`reloadBundles` invalidation** — with reload enabled, a changed bundle 
value is picked up
+   on the next request.
+6. **`clearBundle` / `clearMissingBundlesCache`** — both drop cached entries.
+7. **`ModelDriven` path** — model-class messages still resolve, reusing the 
class-hierarchy cache.
+8. **Package-hierarchy resolution** — a `*.package` key resolves, caches, and 
the class-before-package
+   order is unchanged.
+9. **Regression** — the existing `StrutsLocalizedTextProviderTest` suite and 
related
+   localized-text tests stay green.
+
+## Known limitation
+
+Per decision during design, the new caches are **unbounded** 
`ConcurrentHashMap`s, matching the
+existing `bundlesMap` / `missingBundles`. Because `getText`-style tags can 
accept dynamic /
+user-influenced keys, an unbounded `(class, key, locale)` key space is a 
theoretical
+memory-exhaustion vector. This is consistent with the pre-existing exposure of 
the other caches
+and is accepted here for consistency and simplicity; bounding all 
localized-text caches could be
+revisited as a separate, cross-cutting change.
+
+## Files touched
+
+- 
`core/src/main/java/org/apache/struts2/text/AbstractLocalizedTextProvider.java`
+  — `formatMessage`, `getRawMessage`, `findMessageRaw`, 
`resolveClassHierarchyRaw`,
+  `resolvePackageHierarchyRaw`, `TextCacheKey`, `NOT_FOUND`, the two cache 
maps, and
+  invalidation hooks in `reloadBundles` / `clearBundle` / 
`clearMissingBundlesCache`;
+  `getMessage` re-expressed via `getRawMessage` + `formatMessage`.
+- `core/src/main/java/org/apache/struts2/text/StrutsLocalizedTextProvider.java`
+  — `findText(Class, ...)` rewired to the cached resolvers.
+- 
`core/src/test/java/org/apache/struts2/text/StrutsLocalizedTextProviderTest.java`
+  — new tests.

Reply via email to