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

ddekany pushed a commit to branch FREEMARKER-35
in repository https://gitbox.apache.org/repos/asf/freemarker.git

commit e5cbaa391625f566c6f33a597fe0efb013924421
Author: ddekany <ddek...@apache.org>
AuthorDate: Sun Mar 6 23:51:02 2022 +0100

    [FREEMARKER-35] Added temporal format caching to Environment. 
TemplateTemporalFormat was adjusted for the needs of that.
---
 src/main/java/freemarker/core/Configurable.java    |   4 +-
 src/main/java/freemarker/core/Environment.java     | 336 ++++++++++++++++++++-
 .../ISOLikeTemplateTemporalTemporalFormat.java     |  25 +-
 ...va => JavaOrISOLikeTemplateTemporalFormat.java} |  26 +-
 .../core/JavaTemplateTemporalFormat.java           | 132 ++++----
 .../java/freemarker/core/TemplateDateFormat.java   |   4 +-
 .../freemarker/core/TemplateTemporalFormat.java    |  16 +-
 .../getTemplateTemporalFormatCaching.ftl           |  25 ++
 ...pochMillisDivTemplateTemporalFormatFactory.java |   4 +-
 .../EpochMillisTemplateTemporalFormatFactory.java  |   4 +-
 .../core/HTMLISOTemplateTemporalFormatFactory.java |   8 +-
 .../core/JavaTemplateTemporalFormatTest.java       |  41 +--
 ...ndTZSensitiveTemplateTemporalFormatFactory.java |   4 +-
 ...lateTemporalFormatCachingInEnvironmentTest.java | 248 +++++++++++++++
 14 files changed, 731 insertions(+), 146 deletions(-)

diff --git a/src/main/java/freemarker/core/Configurable.java 
b/src/main/java/freemarker/core/Configurable.java
index 525c25fe..57becfac 100644
--- a/src/main/java/freemarker/core/Configurable.java
+++ b/src/main/java/freemarker/core/Configurable.java
@@ -2950,9 +2950,9 @@ public class Configurable {
             } else if (DATETIME_FORMAT_KEY_SNAKE_CASE.equals(name) || 
DATETIME_FORMAT_KEY_CAMEL_CASE.equals(name)) {
                 setDateTimeFormat(value);
             } else if (YEAR_FORMAT_KEY_SNAKE_CASE.equals(name) || 
YEAR_FORMAT_KEY_CAMEL_CASE.equals(name)) {
-                this.yearFormat = value;
+                setYearFormat(value);
             } else if (YEAR_MONTH_FORMAT_KEY_SNAKE_CASE.equals(name) || 
YEAR_MONTH_FORMAT_KEY_CAMEL_CASE.equals(name)) {
-                this.yearMonthFormat = value;
+                setYearMonthFormat(value);
             } else if (CUSTOM_DATE_FORMATS_KEY_SNAKE_CASE.equals(name)
                     || CUSTOM_DATE_FORMATS_KEY_CAMEL_CASE.equals(name)) {
                 Map map = (Map) _ObjectBuilderSettingEvaluator.eval(
diff --git a/src/main/java/freemarker/core/Environment.java 
b/src/main/java/freemarker/core/Environment.java
index b7153d26..cd2ce83e 100644
--- a/src/main/java/freemarker/core/Environment.java
+++ b/src/main/java/freemarker/core/Environment.java
@@ -29,7 +29,16 @@ import java.text.Collator;
 import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
 import java.text.NumberFormat;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Year;
+import java.time.YearMonth;
 import java.time.ZoneId;
+import java.time.ZonedDateTime;
 import java.time.temporal.Temporal;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -141,9 +150,9 @@ public final class Environment extends Configurable {
     private ZoneId cachedZoneId;
 
     /**
-     * Stores the date/time/date-time formatters that are used when no format 
is explicitly given at the place of
-     * formatting. That is, in situations like ${lastModified} or even 
${lastModified?date}, but not in situations like
-     * ${lastModified?string.iso}.
+     * Stores the date/time/date-time formatters that are used when no format 
is explicitly given where the value is
+     * converted to text. That is, it's used in situations like 
${lastModified} or even ${lastModified?date}, but not in
+     * situations like ${lastModified?string.iso}.
      * 
      * <p>
      * The index of the array is calculated from what kind of formatter we 
want (see
@@ -159,6 +168,85 @@ public final class Environment extends Configurable {
      * first needed.
      */
     private TemplateDateFormat[] cachedTempDateFormatArray;
+
+    /**
+     * Similar to {@link #cachedTempDateFormatArray}, but for {@link 
TemplateTemporalFormat}-s. It's not an array as
+     * {@code java.time} classes have no numerical value, unlike legacy 
FreeMarker date types.
+     */
+    private TemplateTemporalFormatCache cachedTemporalFormatCache;
+    private final class TemplateTemporalFormatCache {
+        // Notes:
+        // - "reusable" fields are set when the current cache field is set
+        // - non-reusable fields are cleared when any related setting is 
changed, but reusableXxx fields are only
+        //   if the format string changes
+        // - When there's a cache-miss, we check if the "reusable" field has 
compatible timeZone, and locale, and if
+        //   so, we copy it back into the non-reusable field, and use it.
+
+        private TemplateTemporalFormat localDateTimeFormat;
+        private TemplateTemporalFormat reusableLocalDateTimeFormat;
+        private TemplateTemporalFormat offsetDateTimeFormat;
+        private TemplateTemporalFormat reusableOffsetDateTimeFormat;
+        private TemplateTemporalFormat zonedDateTimeFormat;
+        private TemplateTemporalFormat reusableZonedDateTimeFormat;
+        private TemplateTemporalFormat localDateFormat;
+        private TemplateTemporalFormat reusableLocalDateFormat;
+        private TemplateTemporalFormat localTimeFormat;
+        private TemplateTemporalFormat reusableLocalTimeFormat;
+        private TemplateTemporalFormat offsetTimeFormat;
+        private TemplateTemporalFormat reusableOffsetTimeFormat;
+        private TemplateTemporalFormat yearMonthFormat;
+        private TemplateTemporalFormat reusableYearMonthFormat;
+        private TemplateTemporalFormat yearFormat;
+        private TemplateTemporalFormat reusableYearFormat;
+        private TemplateTemporalFormat instantFormat;
+        private TemplateTemporalFormat reusableInstantFormat;
+
+        private void evictAfterTimeZoneOrLocaleChange() {
+            localDateTimeFormat = null;
+            offsetDateTimeFormat = null;
+            zonedDateTimeFormat = null;
+            localDateFormat = null;
+            localTimeFormat = null;
+            offsetTimeFormat = null;
+            yearMonthFormat = null;
+            yearFormat = null;
+            instantFormat = null;
+        }
+
+        private void evictAfterDateTimeFormatChange() {
+            localDateTimeFormat = null;
+            reusableLocalDateTimeFormat = null;
+            offsetDateTimeFormat = null;
+            reusableOffsetDateTimeFormat = null;
+            zonedDateTimeFormat = null;
+            reusableZonedDateTimeFormat = null;
+            instantFormat = null;
+            reusableInstantFormat = null;
+        }
+
+        private void evictAfterDateFormatChange() {
+            localDateFormat = null;
+            reusableLocalDateFormat = null;
+        }
+
+        private void evictAfterTimeFormatChange() {
+            localTimeFormat = null;
+            reusableLocalTimeFormat = null;
+            offsetTimeFormat = null;
+            reusableOffsetTimeFormat = null;
+        }
+
+        private void evictAfterYearMonthFormatChange() {
+            yearMonthFormat = null;
+            reusableYearMonthFormat = null;
+        }
+
+        private void evictAfterYearFormatChange() {
+            yearFormat = null;
+            reusableYearFormat = null;
+        }
+    }
+
     /** Similar to {@link #cachedTempDateFormatArray}, but used when a 
formatting string was specified. */
     private HashMap<String, TemplateDateFormat>[] 
cachedTempDateFormatsByFmtStrArray;
     private static final int CACHED_TDFS_ZONELESS_INPUT_OFFS = 4;
@@ -1270,6 +1358,10 @@ public final class Environment extends Configurable {
             cachedTempDateFormatsByFmtStrArray = null;
 
             cachedCollator = null;
+
+            if (cachedTemporalFormatCache != null) {
+                cachedTemporalFormatCache.evictAfterTimeZoneOrLocaleChange();
+            }
         }
     }
 
@@ -1294,6 +1386,10 @@ public final class Environment extends Configurable {
             }
 
             cachedSQLDateAndTimeTimeZoneSameAsNormal = null;
+
+            if (cachedTemporalFormatCache != null) {
+                cachedTemporalFormatCache.evictAfterTimeZoneOrLocaleChange();
+            }
         }
     }
 
@@ -1729,6 +1825,10 @@ public final class Environment extends Configurable {
                     cachedTempDateFormatArray[i + TemplateDateModel.TIME] = 
null;
                 }
             }
+
+            if (cachedTemporalFormatCache != null) {
+                cachedTemporalFormatCache.evictAfterTimeFormatChange();
+            }
         }
     }
 
@@ -1742,6 +1842,10 @@ public final class Environment extends Configurable {
                     cachedTempDateFormatArray[i + TemplateDateModel.DATE] = 
null;
                 }
             }
+
+            if (cachedTemporalFormatCache != null) {
+                cachedTemporalFormatCache.evictAfterDateFormatChange();
+            }
         }
     }
 
@@ -1755,6 +1859,36 @@ public final class Environment extends Configurable {
                     cachedTempDateFormatArray[i + TemplateDateModel.DATETIME] 
= null;
                 }
             }
+
+            if (cachedTemporalFormatCache != null) {
+                cachedTemporalFormatCache.evictAfterDateTimeFormatChange();
+            }
+        }
+    }
+
+    @Override
+    public void setYearFormat(String yearFormat) {
+        if (cachedTemporalFormatCache == null) {
+            super.setYearFormat(yearFormat);
+        } else {
+            String prevYearFormat = getYearFormat();
+            super.setYearFormat(yearFormat);
+            if (!yearFormat.equals(prevYearFormat)) {
+                cachedTemporalFormatCache.evictAfterYearFormatChange();
+            }
+        }
+    }
+
+    @Override
+    public void setYearMonthFormat(String yearMonthFormat) {
+        if (cachedTemporalFormatCache == null) {
+            super.setYearMonthFormat(yearMonthFormat);
+        } else {
+            String prevYearMonthFormat = getYearMonthFormat();
+            super.setYearMonthFormat(yearMonthFormat);
+            if (!yearMonthFormat.equals(prevYearMonthFormat)) {
+                cachedTemporalFormatCache.evictAfterYearMonthFormatChange();
+            }
         }
     }
 
@@ -2363,10 +2497,195 @@ public final class Environment extends Configurable {
         }
     }
 
-    TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends Temporal> 
temporalClass)
+    /**
+     * Returns the current format for the given temporal class.
+     *
+     * @since 2.3.31
+     */
+    public TemplateTemporalFormat getTemplateTemporalFormat(Class<? extends 
Temporal> temporalClass)
             throws TemplateValueFormatException {
-        // TODO [FREEMARKER-35] Temporal class keyed cache, invalidated by 
temporalFormat (instantFormat, localDateFormat, etc.), locale, and timeZone 
change.
-        return getTemplateTemporalFormat(getTemporalFormat(temporalClass), 
temporalClass);
+        temporalClass = 
_TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
+        if (cachedTemporalFormatCache == null) {
+            cachedTemporalFormatCache = new TemplateTemporalFormatCache();
+        }
+
+        TemplateTemporalFormat result;
+
+        // BEGIN Generated with getTemplateTemporalFormatCaching.ftl
+        if (temporalClass == LocalDateTime.class) {
+            result = cachedTemporalFormatCache.localDateTimeFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableLocalDateTimeFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.localDateTimeFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.localDateTimeFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableLocalDateTimeFormat = result;
+            return result;
+        }
+        if (temporalClass == Instant.class) {
+            result = cachedTemporalFormatCache.instantFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableInstantFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.instantFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.instantFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableInstantFormat = result;
+            return result;
+        }
+        if (temporalClass == LocalDate.class) {
+            result = cachedTemporalFormatCache.localDateFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableLocalDateFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.localDateFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.localDateFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableLocalDateFormat = result;
+            return result;
+        }
+        if (temporalClass == LocalTime.class) {
+            result = cachedTemporalFormatCache.localTimeFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableLocalTimeFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.localTimeFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.localTimeFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableLocalTimeFormat = result;
+            return result;
+        }
+        if (temporalClass == ZonedDateTime.class) {
+            result = cachedTemporalFormatCache.zonedDateTimeFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableZonedDateTimeFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.zonedDateTimeFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.zonedDateTimeFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableZonedDateTimeFormat = result;
+            return result;
+        }
+        if (temporalClass == OffsetDateTime.class) {
+            result = cachedTemporalFormatCache.offsetDateTimeFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableOffsetDateTimeFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.offsetDateTimeFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.offsetDateTimeFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableOffsetDateTimeFormat = result;
+            return result;
+        }
+        if (temporalClass == OffsetTime.class) {
+            result = cachedTemporalFormatCache.offsetTimeFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableOffsetTimeFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.offsetTimeFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.offsetTimeFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableOffsetTimeFormat = result;
+            return result;
+        }
+        if (temporalClass == YearMonth.class) {
+            result = cachedTemporalFormatCache.yearMonthFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableYearMonthFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.yearMonthFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.yearMonthFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableYearMonthFormat = result;
+            return result;
+        }
+        if (temporalClass == Year.class) {
+            result = cachedTemporalFormatCache.yearFormat;
+            if (result != null) {
+                return result;
+            }
+
+            result = cachedTemporalFormatCache.reusableYearFormat;
+            if (result != null
+                    && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+                cachedTemporalFormatCache.yearFormat = result;
+                return result;
+            }
+
+            result = 
getTemplateTemporalFormat(getTemporalFormat(temporalClass), temporalClass);
+            cachedTemporalFormatCache.yearFormat = result;
+            // We do this ahead of time, to decrease the cost of evictions:
+            cachedTemporalFormatCache.reusableYearFormat = result;
+            return result;
+        }
+        // END Generated with getTemplateTemporalFormatCaching.ftl
+
+        throw new AssertionError("Unhandled case: " + temporalClass);
     }
 
     /**
@@ -2406,8 +2725,7 @@ public final class Environment extends Configurable {
 
     private TemplateTemporalFormat getTemplateTemporalFormat(String 
formatString, Class<? extends Temporal> temporalClass)
             throws TemplateValueFormatException {
-        // TODO [FREEMARKER-35] format keyed cache, invalidated by locale, and 
timeZone change.
-        return getTemplateTemporalFormatWithoutCache(formatString, 
temporalClass, getLocale(), getTimeZone());
+        return getTemplateTemporalFormat(formatString, temporalClass, 
getLocale(), getTimeZone());
     }
 
     /**
@@ -2422,7 +2740,7 @@ public final class Environment extends Configurable {
      * @param zonelessInput
      *            See the similar parameter of {@link 
TemplateTemporalFormatFactory#get}
      */
-    private TemplateTemporalFormat getTemplateTemporalFormatWithoutCache(
+    private TemplateTemporalFormat getTemplateTemporalFormat(
             String formatString, Class<? extends Temporal> temporalClass, 
Locale locale, TimeZone timeZone)
             throws TemplateValueFormatException {
         final int formatStringLen = formatString.length();
diff --git 
a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java 
b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
index 07d6b8e4..680546dd 100644
--- a/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
+++ b/src/main/java/freemarker/core/ISOLikeTemplateTemporalTemporalFormat.java
@@ -31,6 +31,7 @@ import java.time.YearMonth;
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.ChronoUnit;
 import java.time.temporal.Temporal;
+import java.util.Locale;
 import java.util.TimeZone;
 import java.util.regex.Pattern;
 
@@ -44,7 +45,7 @@ import freemarker.template.TemplateTemporalModel;
  *
  * @since 2.3.32
  */
-final class ISOLikeTemplateTemporalTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalFormat {
+final class ISOLikeTemplateTemporalTemporalFormat extends 
JavaOrISOLikeTemplateTemporalFormat {
     private final DateTimeFormatter dateTimeFormatter;
     private final boolean instantConversion;
     private final String description;
@@ -55,8 +56,8 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
DateTimeFormatBasedTem
             DateTimeFormatter dateTimeFormatter,
             DateTimeFormatter parserExtendedDateTimeFormatter,
             DateTimeFormatter parserBasicDateTimeFormatter,
-            Class<? extends Temporal> temporalClass, TimeZone zone, String 
formatString) {
-        super(temporalClass, zone.toZoneId());
+            Class<? extends Temporal> temporalClass, TimeZone timeZone, String 
formatString) {
+        super(temporalClass, timeZone);
         temporalClass = normalizeSupportedTemporalClass(temporalClass);
         this.dateTimeFormatter = dateTimeFormatter;
         this.parserExtendedDateTimeFormatter = parserExtendedDateTimeFormatter;
@@ -85,8 +86,6 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
DateTimeFormatBasedTem
 
     @Override
     public Object parse(String s, MissingTimeZoneParserPolicy 
missingTimeZoneParserPolicy) throws TemplateValueFormatException {
-        // TODO [FREEMARKER-35] Implement missingTimeZoneParserPolicy
-
         final boolean extendedFormat;
         final boolean add1Day;
         if (temporalClass == LocalDate.class || temporalClass == 
YearMonth.class) {
@@ -124,9 +123,11 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
DateTimeFormatBasedTem
             }
         }
 
-        DateTimeFormatter parserDateTimeFormatter = 
parserBasicDateTimeFormatter == null || extendedFormat
-                ? parserExtendedDateTimeFormatter : 
parserBasicDateTimeFormatter;
-        Temporal resultTemporal = parse(s, missingTimeZoneParserPolicy, 
parserDateTimeFormatter);
+        Temporal resultTemporal = parse(
+                s, missingTimeZoneParserPolicy,
+                parserBasicDateTimeFormatter == null || extendedFormat
+                        ? parserExtendedDateTimeFormatter : 
parserBasicDateTimeFormatter);
+
         if (add1Day) {
             resultTemporal = resultTemporal.plus(1, ChronoUnit.DAYS);
         }
@@ -162,13 +163,7 @@ final class ISOLikeTemplateTemporalTemporalFormat extends 
DateTimeFormatBasedTem
     }
 
     @Override
-    public boolean isLocaleBound() {
-        return false;
-    }
-
-    @Override
-    public boolean isTimeZoneBound() {
-        // TODO [FREEMARKER-35] Even for local temporals?
+    public boolean canBeUsedForLocale(Locale locale) {
         return true;
     }
 
diff --git 
a/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java
similarity index 87%
rename from 
src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java
rename to src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java
index eb8ee951..b83ec05d 100644
--- 
a/src/main/java/freemarker/core/DateTimeFormatBasedTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaOrISOLikeTemplateTemporalFormat.java
@@ -33,21 +33,29 @@ import java.time.format.DateTimeFormatter;
 import java.time.temporal.ChronoField;
 import java.time.temporal.Temporal;
 import java.time.temporal.TemporalAccessor;
+import java.util.Objects;
+import java.util.TimeZone;
 
 /**
  * Was created ad-hoc to contain whatever happens to be common between some of 
our {@link TemplateTemporalFormat}-s.
  */
-abstract class DateTimeFormatBasedTemplateTemporalFormat extends 
TemplateTemporalFormat {
+abstract class JavaOrISOLikeTemplateTemporalFormat extends 
TemplateTemporalFormat {
     protected final Class<? extends Temporal> temporalClass;
     protected final boolean isLocalTemporalClass;
+    protected final TimeZone timeZone;
     protected final ZoneId zoneId;
 
-    public DateTimeFormatBasedTemplateTemporalFormat(
-            Class<? extends Temporal> temporalClass, ZoneId zoneId) {
-        temporalClass = 
_TemporalUtils.normalizeSupportedTemporalClass(temporalClass);
-        this.temporalClass = temporalClass;
-        this.isLocalTemporalClass = isLocalTemporalClass(temporalClass);
-        this.zoneId = zoneId;
+    public JavaOrISOLikeTemplateTemporalFormat(
+            Class<? extends Temporal> temporalClass, TimeZone timeZone) {
+        this.temporalClass = 
Objects.requireNonNull(_TemporalUtils.normalizeSupportedTemporalClass(temporalClass));
+        this.isLocalTemporalClass = isLocalTemporalClass(this.temporalClass);
+        if (isLocalTemporalClass) {
+            this.zoneId = null;
+            this.timeZone = null;
+        } else {
+            this.timeZone = Objects.requireNonNull(timeZone);
+            this.zoneId = timeZone.toZoneId();
+        }
     }
 
     protected Temporal parse(
@@ -140,4 +148,8 @@ abstract class DateTimeFormatBasedTemplateTemporalFormat 
extends TemplateTempora
                 e);
     }
 
+    @Override
+    public final boolean canBeUsedForTimeZone(TimeZone timeZone) {
+        return this.timeZone == null || this.timeZone.equals(timeZone);
+    }
 }
diff --git a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java 
b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
index 718f70bb..dc7845af 100644
--- a/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/JavaTemplateTemporalFormat.java
@@ -27,13 +27,13 @@ import java.time.LocalDateTime;
 import java.time.LocalTime;
 import java.time.OffsetDateTime;
 import java.time.OffsetTime;
-import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import java.time.format.FormatStyle;
 import java.time.temporal.Temporal;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.TimeZone;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -47,9 +47,10 @@ import freemarker.template.utility.ClassUtil;
  *
  * @since 2.3.32
  */
-class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalFormat {
+class JavaTemplateTemporalFormat extends JavaOrISOLikeTemplateTemporalFormat {
 
     enum PreFormatValueConversion {
+        IDENTITY,
         INSTANT_TO_ZONED_DATE_TIME,
         AS_LOCAL_IN_CURRENT_ZONE,
         SET_ZONE_FROM_OFFSET,
@@ -70,15 +71,16 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
     private static final Pattern FORMAT_STYLE_PATTERN = Pattern.compile(
             "(?:" + ANY_FORMAT_STYLE + "(?:_" + ANY_FORMAT_STYLE + ")?)?");
 
+    private final Locale locale;
     private final DateTimeFormatter dateTimeFormatter;
-    private final ZoneId zoneId;
     private final String formatString;
     private final PreFormatValueConversion preFormatValueConversion;
 
     JavaTemplateTemporalFormat(String formatString, Class<? extends Temporal> 
temporalClass, Locale locale,
             TimeZone timeZone)
             throws InvalidFormatParametersException {
-        super(temporalClass, timeZone.toZoneId());
+        super(temporalClass, timeZone);
+        this.locale = Objects.requireNonNull(locale);
 
         final Matcher formatStylePatternMatcher = 
FORMAT_STYLE_PATTERN.matcher(formatString);
         final boolean isFormatStyleString = 
formatStylePatternMatcher.matches();
@@ -124,7 +126,7 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
 
         // Handling of time zone related edge cases
         if (isLocalTemporalClass) {
-            this.preFormatValueConversion = null;
+            this.preFormatValueConversion = PreFormatValueConversion.IDENTITY;
             if (isFormatStyleString && (temporalClass == LocalTime.class || 
temporalClass == LocalDateTime.class)) {
                 // The localized pattern possibly contains the time zone (for 
most locales, LONG and FULL does), so they
                 // fail with local temporals that have a time part. To work 
this issue around, we decrease the verbosity
@@ -163,7 +165,7 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
                         (temporalClass == OffsetDateTime.class || 
temporalClass == OffsetTime.class)) {
                     preFormatValueConversion = 
PreFormatValueConversion.SET_ZONE_FROM_OFFSET;
                 } else {
-                    preFormatValueConversion = null;
+                    preFormatValueConversion = 
PreFormatValueConversion.IDENTITY;
                 }
             } else { // Doesn't show zone
                 if (temporalClass == OffsetTime.class) {
@@ -172,8 +174,8 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
                     preFormatValueConversion =
                             
PreFormatValueConversion.OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION;
                 } else {
-                    // As no zone is shown, but our temporal class is not 
local, we tell the formatter convert to
-                    // the current time zone. Also, when parsing, that same 
time zone will be assumed.
+                    // As no zone is shown, but our temporal class is not 
local, the formatter will convert to a local
+                    // in the current time zone.
                     preFormatValueConversion = 
PreFormatValueConversion.AS_LOCAL_IN_CURRENT_ZONE;
                 }
             }
@@ -183,7 +185,6 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
         dateTimeFormatter = dateTimeFormatter.withLocale(locale);
         this.dateTimeFormatter = dateTimeFormatter;
         this.formatString = formatString;
-        this.zoneId = timeZone.toZoneId();
     }
 
     @Override
@@ -192,46 +193,46 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
         DateTimeFormatter dateTimeFormatter = this.dateTimeFormatter;
         Temporal temporal = TemplateFormatUtil.getNonNullTemporal(tm);
 
-        if (preFormatValueConversion != null) {
-            switch (preFormatValueConversion) {
-                case INSTANT_TO_ZONED_DATE_TIME:
-                    // Typical date-time formats will fail with 
"UnsupportedTemporalTypeException: Unsupported field:
-                    // YearOfEra" if we leave the value as Instant. (But 
parse(String, Instant::from) has no similar
-                    // issue.)
-                    temporal = ((Instant) temporal).atZone(zoneId);
-                    break;
-                case SET_ZONE_FROM_OFFSET:
-                    // Formats like "long" want a time zone field, but oddly, 
they don't treat the zoneOffset as such.
-                    if (temporal instanceof OffsetDateTime) {
-                        OffsetDateTime offsetDateTime = (OffsetDateTime) 
temporal;
-                        temporal = 
ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
-                    } else if (temporal instanceof OffsetTime) {
-                        // There's no ZonedTime class, so we must manipulate 
the format.
-                        dateTimeFormatter = 
dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
-                    } else {
-                        throw new IllegalArgumentException(
-                                "Formatter was created for OffsetTime or 
OffsetDateTime, but value was a "
-                                        + 
ClassUtil.getShortClassNameOfObject(temporal));
-                    }
-                    break;
-                case AS_LOCAL_IN_CURRENT_ZONE:
-                    // We could use dateTimeFormatter.withZone(zoneId) for 
these, but it's not obvious if that will
-                    // always behave as a straightforward conversion to the 
local temporal type.
-                    if (temporal instanceof OffsetDateTime) {
-                        temporal = ((OffsetDateTime) 
temporal).atZoneSameInstant(zoneId).toLocalDateTime();
-                    } else if (temporal instanceof ZonedDateTime) {
-                        temporal = ((ZonedDateTime) 
temporal).withZoneSameInstant(zoneId).toLocalDateTime();
-                    } else if (temporal instanceof Instant) {
-                        temporal = ((Instant) 
temporal).atZone(zoneId).toLocalDateTime();
-                    } else {
-                        throw new AssertionError("Unhandled case: " + 
temporal.getClass());
-                    }
-                    break;
-                case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION:
-                    throw newOffsetTimeWithoutOffsetOnTheFormatException();
-                default:
-                    throw new BugException();
-            }
+        switch (preFormatValueConversion) {
+            case IDENTITY:
+                break;
+            case INSTANT_TO_ZONED_DATE_TIME:
+                // Typical date-time formats will fail with 
"UnsupportedTemporalTypeException: Unsupported field:
+                // YearOfEra" if we leave the value as Instant. (But 
parse(String, Instant::from) has no similar
+                // issue.)
+                temporal = ((Instant) temporal).atZone(zoneId);
+                break;
+            case SET_ZONE_FROM_OFFSET:
+                // Formats like "long" want a time zone field, but oddly, they 
don't treat the zoneOffset as such.
+                if (temporal instanceof OffsetDateTime) {
+                    OffsetDateTime offsetDateTime = (OffsetDateTime) temporal;
+                    temporal = 
ZonedDateTime.of(offsetDateTime.toLocalDateTime(), offsetDateTime.getOffset());
+                } else if (temporal instanceof OffsetTime) {
+                    // There's no ZonedTime class, so we must manipulate the 
format.
+                    dateTimeFormatter = 
dateTimeFormatter.withZone(((OffsetTime) temporal).getOffset());
+                } else {
+                    throw new IllegalArgumentException(
+                            "Formatter was created for OffsetTime or 
OffsetDateTime, but value was a "
+                                    + 
ClassUtil.getShortClassNameOfObject(temporal));
+                }
+                break;
+            case AS_LOCAL_IN_CURRENT_ZONE:
+                // We could use dateTimeFormatter.withZone(zoneId) for these, 
but it's not obvious that that will
+                // always behave as a straightforward conversion to the local 
temporal type.
+                if (temporal instanceof OffsetDateTime) {
+                    temporal = ((OffsetDateTime) 
temporal).atZoneSameInstant(zoneId).toLocalDateTime();
+                } else if (temporal instanceof ZonedDateTime) {
+                    temporal = ((ZonedDateTime) 
temporal).withZoneSameInstant(zoneId).toLocalDateTime();
+                } else if (temporal instanceof Instant) {
+                    temporal = ((Instant) 
temporal).atZone(zoneId).toLocalDateTime();
+                } else {
+                    throw new AssertionError("Unhandled case: " + 
temporal.getClass());
+                }
+                break;
+            case OFFSET_TIME_WITHOUT_OFFSET_ON_THE_FORMAT_EXCEPTION:
+                throw newOffsetTimeWithoutOffsetOnTheFormatException();
+            default:
+                throw new BugException();
         }
 
         try {
@@ -255,25 +256,13 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
     }
 
     @Override
-    public String getDescription() {
-        return formatString;
-    }
-
-    /**
-     * Tells if this formatter should be re-created if the locale changes.
-     */
-    @Override
-    public boolean isLocaleBound() {
-        return true;
+    public boolean canBeUsedForLocale(Locale locale) {
+        return this.locale.equals(locale);
     }
 
-    /**
-     * Tells if this formatter should be re-created if the time zone changes.
-     */
     @Override
-    public boolean isTimeZoneBound() {
-        // TODO [FREEMARKER-35] Even for local temporals?
-        return true;
+    public String getDescription() {
+        return formatString;
     }
 
     private static final ZonedDateTime SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_1 
= ZonedDateTime.of(
@@ -286,19 +275,6 @@ class JavaTemplateTemporalFormat extends 
DateTimeFormatBasedTemplateTemporalForm
                 
.equals(dateTimeFormatter.format(SHOWS_OFFSET_OR_ZONE_SAMPLE_TEMPORAL_2));
     }
 
-    private static FormatStyle getMoreVerboseStyle(FormatStyle style) {
-        switch (style) {
-            case SHORT:
-                return FormatStyle.MEDIUM;
-            case MEDIUM:
-                return FormatStyle.LONG;
-            case LONG:
-                return FormatStyle.FULL;
-            default:
-                return null;
-        }
-    }
-
     private static FormatStyle getLessVerboseStyle(FormatStyle style) {
         switch (style) {
             case FULL:
diff --git a/src/main/java/freemarker/core/TemplateDateFormat.java 
b/src/main/java/freemarker/core/TemplateDateFormat.java
index ec75627a..da2cfab3 100644
--- a/src/main/java/freemarker/core/TemplateDateFormat.java
+++ b/src/main/java/freemarker/core/TemplateDateFormat.java
@@ -28,12 +28,14 @@ import freemarker.template.TemplateModelException;
 /**
  * Represents a date/time/dateTime format; used in templates for formatting 
and parsing with that format. This is
  * similar to Java's {@link DateFormat}, but made to fit the requirements of 
FreeMarker. Also, it makes easier to define
- * formats that can't be represented with Java's existing {@link DateFormat} 
implementations.
+ * formats that can't be described with Java's existing {@link DateFormat} 
implementations.
  * 
  * <p>
  * Implementations need not be thread-safe if the {@link 
TemplateDateFormatFactory} doesn't recycle them among
  * different {@link Environment}-s. As far as FreeMarker's concerned, 
instances are bound to a single
  * {@link Environment}, and {@link Environment}-s are thread-local objects.
+ *
+ * @see TemplateTemporalFormat
  * 
  * @since 2.3.24
  */
diff --git a/src/main/java/freemarker/core/TemplateTemporalFormat.java 
b/src/main/java/freemarker/core/TemplateTemporalFormat.java
index 2c1dc263..a282093d 100644
--- a/src/main/java/freemarker/core/TemplateTemporalFormat.java
+++ b/src/main/java/freemarker/core/TemplateTemporalFormat.java
@@ -20,6 +20,8 @@ package freemarker.core;
 
 import java.time.format.DateTimeFormatter;
 import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
 
 import freemarker.template.TemplateModelException;
 import freemarker.template.TemplateTemporalModel;
@@ -27,7 +29,7 @@ import freemarker.template.TemplateTemporalModel;
 /**
  * Represents a {@link Temporal} format; used in templates for formatting 
{@link Temporal}-s, and parsing strings to
  * {@link Temporal}-s. This is similar to Java's {@link DateTimeFormatter}, 
but made to fit the requirements of
- * FreeMarker. Also, it makes it possible to define formats that can't be 
represented with {@link DateTimeFormatter}.
+ * FreeMarker. Also, it makes it possible to define formats that can't be 
described with {@link DateTimeFormatter}.
  *
  * <p>{@link TemplateTemporalFormat} instances are usually created by a {@link 
TemplateTemporalFormatFactory}.
  *
@@ -37,6 +39,8 @@ import freemarker.template.TemplateTemporalModel;
  * {@link TemplateTemporalFormat} instances in multiple {@link Environment}-s, 
and an {@link Environment} is only used
  * in a single thread.
  *
+ * @see TemplateDateFormat
+ *
  * @since 2.3.32
  */
 public abstract class TemplateTemporalFormat extends TemplateValueFormat {
@@ -59,16 +63,14 @@ public abstract class TemplateTemporalFormat extends 
TemplateValueFormat {
     }
 
     /**
-     * Tells if the same formatter can be used regardless of the desired 
locale (so for example after a
-     * {@link Environment#getLocale()} change we can keep using the old 
instance).
+     * Tells if this formatter can be used for the given locale.
      */
-    public abstract boolean isLocaleBound();
+    public abstract boolean canBeUsedForLocale(Locale locale);
 
     /**
-     * Tells if the same formatter can be used regardless of the desired time 
zone (so for example after a
-     * {@link Environment#getTimeZone()} change we can keep using the old 
instance).
+     * Tells if this formatter can be used for the given {@link TimeZone}.
      */
-    public abstract boolean isTimeZoneBound();
+    public abstract boolean canBeUsedForTimeZone(TimeZone timeZone);
 
     /**
      * Parser a string to a {@link Temporal}, according to this format. Some 
format implementations may throw
diff --git 
a/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl
 
b/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl
new file mode 100644
index 00000000..5fd50ed2
--- /dev/null
+++ 
b/src/main/misc/templateTemporalFormatCache/getTemplateTemporalFormatCaching.ftl
@@ -0,0 +1,25 @@
+// BEGIN Generated with getTemplateTemporalFormatCaching.ftl
+<#-- Classes are in order of frequency (guessed). -->
+<#list ['LocalDateTime', 'Instant', 'LocalDate', 'LocalTime', 'ZonedDateTime', 
'OffsetDateTime', 'OffsetTime', 'YearMonth', 'Year'] as TemporalClass>
+  <#assign temporalClass = TemporalClass[0]?lowerCase + TemporalClass[1..]>
+  if (temporalClass == ${TemporalClass}.class) {
+      result = cachedTemporalFormatCache.${temporalClass}Format;
+      if (result != null) {
+          return result;
+      }
+
+      result = cachedTemporalFormatCache.reusable${TemporalClass}Format;
+      if (result != null
+              && result.canBeUsedForTimeZone(getTimeZone()) && 
result.canBeUsedForLocale(getLocale())) {
+          cachedTemporalFormatCache.${temporalClass}Format = result;
+          return result;
+      }
+
+      result = getTemplateTemporalFormat(getTemporalFormat(temporalClass), 
temporalClass);
+      cachedTemporalFormatCache.${temporalClass}Format = result;
+      // We do this ahead of time, to decrease the cost of evictions:
+      cachedTemporalFormatCache.reusable${TemporalClass}Format = result;
+      return result;
+  }
+</#list>
+// END Generated with getTemplateTemporalFormatCaching.ftl
diff --git 
a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
 
b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
index 4c5174d3..414ae114 100644
--- 
a/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
+++ 
b/src/test/java/freemarker/core/EpochMillisDivTemplateTemporalFormatFactory.java
@@ -83,12 +83,12 @@ public class EpochMillisDivTemplateTemporalFormatFactory 
extends TemplateTempora
         }
 
         @Override
-        public boolean isLocaleBound() {
+        public boolean canBeUsedForLocale(Locale locale) {
             return false;
         }
 
         @Override
-        public boolean isTimeZoneBound() {
+        public boolean canBeUsedForTimeZone(TimeZone timeZone) {
             return false;
         }
 
diff --git 
a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java 
b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
index 4eaf4102..2094f1d6 100644
--- 
a/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
+++ 
b/src/test/java/freemarker/core/EpochMillisTemplateTemporalFormatFactory.java
@@ -69,12 +69,12 @@ public class EpochMillisTemplateTemporalFormatFactory 
extends TemplateTemporalFo
         }
 
         @Override
-        public boolean isLocaleBound() {
+        public boolean canBeUsedForLocale(Locale locale) {
             return false;
         }
 
         @Override
-        public boolean isTimeZoneBound() {
+        public boolean canBeUsedForTimeZone(TimeZone timeZone) {
             return false;
         }
 
diff --git 
a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java 
b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
index 36c3c7e5..b8492039 100644
--- a/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
+++ b/src/test/java/freemarker/core/HTMLISOTemplateTemporalFormatFactory.java
@@ -65,13 +65,13 @@ public class HTMLISOTemplateTemporalFormatFactory extends 
TemplateTemporalFormat
         }
 
         @Override
-        public boolean isLocaleBound() {
-            return false;
+        public boolean canBeUsedForLocale(Locale locale) {
+            return isoFormat.canBeUsedForLocale(locale);
         }
 
         @Override
-        public boolean isTimeZoneBound() {
-            return false;
+        public boolean canBeUsedForTimeZone(TimeZone timeZone) {
+            return isoFormat.canBeUsedForTimeZone(timeZone);
         }
 
         @Override
diff --git a/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java 
b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
index a81841f1..2c68309d 100644
--- a/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
+++ b/src/test/java/freemarker/core/JavaTemplateTemporalFormatTest.java
@@ -278,23 +278,30 @@ public class JavaTemplateTemporalFormatTest extends 
AbstractTemporalFormatTest {
 
     @Test
     public void testParseWrongFormat() throws TemplateException, 
TemplateValueFormatException {
-        try {
-            assertParsingResults(
-                    conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"),
-                    "2020-12-10 01:14 PM", LocalDateTime.of(2020, 12, 10, 13, 
14));
-            fail("Parsing should have failed");
-        } catch (UnparsableValueException e) {
-            assertThat(
-                    e.getMessage(),
-                    allOf(
-                            containsString("\"2020-12-10 01:14 PM\""),
-                            containsString("\"y-MM-dd HH:mm\""),
-                            containsString("\"en_US\""),
-                            containsString("\"UTC\""),
-                            containsString("LocalDateTime")
-                    )
-            );
-        }
+        assertParsingFails(
+                conf -> conf.setDateTimeFormat("y-MM-dd HH:mm"),
+                "2020-12-10 01:14 PM",
+                LocalDateTime.class,
+                e -> assertThat(
+                e.getMessage(),
+                allOf(
+                        containsString("\"2020-12-10 01:14 PM\""),
+                        containsString("\"y-MM-dd HH:mm\""),
+                        containsString("\"en_US\""),
+                        not(containsString("\"UTC\"")), // Because local 
formats don't depend on timeZone
+                        containsString(LocalDateTime.class.getSimpleName()))));
+        assertParsingFails(
+                conf -> conf.setDateTimeFormat("y-MM-dd HH:mm X"),
+                "2020-12-10 01:14 PM",
+                ZonedDateTime.class,
+                e -> assertThat(
+                        e.getMessage(),
+                        allOf(
+                                containsString("\"2020-12-10 01:14 PM\""),
+                                containsString("\"y-MM-dd HH:mm X\""),
+                                containsString("\"en_US\""),
+                                containsString("\"UTC\""), //
+                                
containsString(ZonedDateTime.class.getSimpleName()))));
     }
 
     @Test
diff --git 
a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
 
b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
index b58ecd4d..7051b75d 100644
--- 
a/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
+++ 
b/src/test/java/freemarker/core/LocAndTZSensitiveTemplateTemporalFormatFactory.java
@@ -74,12 +74,12 @@ public class LocAndTZSensitiveTemplateTemporalFormatFactory 
extends TemplateTemp
         }
 
         @Override
-        public boolean isLocaleBound() {
+        public boolean canBeUsedForLocale(Locale locale) {
             return true;
         }
 
         @Override
-        public boolean isTimeZoneBound() {
+        public boolean canBeUsedForTimeZone(TimeZone timeZone) {
             return true;
         }
 
diff --git 
a/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java
 
b/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java
new file mode 100644
index 00000000..0268f92a
--- /dev/null
+++ 
b/src/test/java/freemarker/core/TemplateTemporalFormatCachingInEnvironmentTest.java
@@ -0,0 +1,248 @@
+/*
+ * 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 freemarker.core;
+
+import static org.junit.Assert.*;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.Year;
+import java.time.YearMonth;
+import java.time.ZonedDateTime;
+import java.time.temporal.Temporal;
+import java.util.Locale;
+import java.util.TimeZone;
+
+import org.junit.Test;
+
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+import freemarker.template.utility.DateUtil;
+import freemarker.template.utility.NullWriter;
+
+public class TemplateTemporalFormatCachingInEnvironmentTest {
+
+    @Test
+    public void testTemporalClassSeparation() throws Exception {
+        Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
+        cfg.setLocale(Locale.US);
+        cfg.setTimeZone(DateUtil.UTC);
+
+        Environment env = new Template(null, "", cfg)
+                .createProcessingEnvironment(null, NullWriter.INSTANCE);
+
+        env.setDateTimeFormat("iso");
+        TemplateTemporalFormat lastLocalDateTimeFormat = 
env.getTemplateTemporalFormat(LocalDateTime.class);
+        TemplateTemporalFormat lastOffsetDateTimeFormat = 
env.getTemplateTemporalFormat(OffsetDateTime.class);
+        TemplateTemporalFormat lastZonedDateTimeFormat = 
env.getTemplateTemporalFormat(ZonedDateTime.class);
+        TemplateTemporalFormat lastInstantDateTimeFormat = 
env.getTemplateTemporalFormat(Instant.class);
+        TemplateTemporalFormat lastOffsetTimeFormat = 
env.getTemplateTemporalFormat(OffsetTime.class);
+        TemplateTemporalFormat lastLocalTimeFormat = 
env.getTemplateTemporalFormat(LocalTime.class);
+        TemplateTemporalFormat lastLocalDateFormat = 
env.getTemplateTemporalFormat(LocalDate.class);
+        TemplateTemporalFormat lastYearFormat = 
env.getTemplateTemporalFormat(Year.class);
+        TemplateTemporalFormat lastYearMonthFormat = 
env.getTemplateTemporalFormat(YearMonth.class);
+        env.setDateTimeFormat("long");
+        assertNotSame(lastLocalDateTimeFormat, 
env.getTemplateTemporalFormat(LocalDateTime.class));
+        assertNotSame(lastOffsetDateTimeFormat, 
env.getTemplateTemporalFormat(OffsetDateTime.class));
+        assertNotSame(lastZonedDateTimeFormat, 
env.getTemplateTemporalFormat(ZonedDateTime.class));
+        assertNotSame(lastInstantDateTimeFormat, 
env.getTemplateTemporalFormat(Instant.class));
+        assertSame(lastOffsetTimeFormat, 
env.getTemplateTemporalFormat(OffsetTime.class));
+        assertSame(lastLocalTimeFormat, 
env.getTemplateTemporalFormat(LocalTime.class));
+        assertSame(lastLocalDateFormat, 
env.getTemplateTemporalFormat(LocalDate.class));
+        assertSame(lastYearFormat, env.getTemplateTemporalFormat(Year.class));
+        assertSame(lastYearMonthFormat, 
env.getTemplateTemporalFormat(YearMonth.class));
+
+        lastLocalDateTimeFormat = 
env.getTemplateTemporalFormat(LocalDateTime.class);
+        lastOffsetDateTimeFormat = 
env.getTemplateTemporalFormat(OffsetDateTime.class);
+        lastZonedDateTimeFormat = 
env.getTemplateTemporalFormat(ZonedDateTime.class);
+        lastInstantDateTimeFormat = 
env.getTemplateTemporalFormat(Instant.class);
+        lastOffsetTimeFormat = env.getTemplateTemporalFormat(OffsetTime.class);
+        lastLocalTimeFormat = env.getTemplateTemporalFormat(LocalTime.class);
+        lastLocalDateFormat = env.getTemplateTemporalFormat(LocalDate.class);
+        lastYearFormat = env.getTemplateTemporalFormat(Year.class);
+        lastYearMonthFormat = env.getTemplateTemporalFormat(YearMonth.class);
+        env.setTimeFormat("short");
+        assertSame(lastLocalDateTimeFormat, 
env.getTemplateTemporalFormat(LocalDateTime.class));
+        assertSame(lastOffsetDateTimeFormat, 
env.getTemplateTemporalFormat(OffsetDateTime.class));
+        assertSame(lastZonedDateTimeFormat, 
env.getTemplateTemporalFormat(ZonedDateTime.class));
+        assertSame(lastInstantDateTimeFormat, 
env.getTemplateTemporalFormat(Instant.class));
+        assertNotSame(lastOffsetTimeFormat, 
env.getTemplateTemporalFormat(OffsetTime.class));
+        assertNotSame(lastLocalTimeFormat, 
env.getTemplateTemporalFormat(LocalTime.class));
+        assertSame(lastLocalDateFormat, 
env.getTemplateTemporalFormat(LocalDate.class));
+        assertSame(lastYearFormat, env.getTemplateTemporalFormat(Year.class));
+        assertSame(lastYearMonthFormat, 
env.getTemplateTemporalFormat(YearMonth.class));
+    }
+
+    @Test
+    public void testForDateTime() throws Exception {
+        // Locale dependent formatters:
+        genericTest(LocalDateTime.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd 
HH:mm" : "yyyyMMddHHmm"),
+                true, false);
+        genericTest(ZonedDateTime.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd 
HH:mm" : "yyyyMMddHHmm"),
+                true, true);
+        genericTest(OffsetDateTime.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd 
HH:mm" : "yyyyMMddHHmm"),
+                true, true);
+        genericTest(Instant.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "yyyy-MM-dd 
HH:mm" : "yyyyMMddHHmm"),
+                true, true);
+
+        // Locale independent formatters:
+        genericTest(LocalDateTime.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "iso" : "xs"),
+                false, false);
+        genericTest(ZonedDateTime.class,
+                (cfg, first) -> cfg.setDateTimeFormat(first ? "iso" : "xs"),
+                false, true);
+    }
+
+    @Test
+    public void testForDate() throws Exception {
+        // Locale dependent formatters:
+        genericTest(LocalDate.class,
+                (cfg, first) -> cfg.setDateFormat(first ? "yyyy-MM-dd" : 
"yyyyMM-dd"),
+                true, false);
+
+        // Locale independent formatters:
+        genericTest(LocalDate.class,
+                (cfg, first) -> cfg.setDateFormat(first ? "iso" : "xs"),
+                false, false);
+    }
+
+    @Test
+    public void testForTime() throws Exception {
+        // Locale dependent formatters:
+        genericTest(LocalTime.class,
+                (cfg, first) -> cfg.setTimeFormat(first ? "HH:mm" : "HHmm"),
+                true, false);
+        genericTest(OffsetTime.class,
+                (cfg, first) -> cfg.setTimeFormat(first ? "HH:mm" : "HHmm"),
+                true, true);
+
+        // Locale independent formatters:
+        genericTest(LocalTime.class,
+                (cfg, first) -> cfg.setTimeFormat(first ? "iso" : "xs"),
+                false, false);
+        genericTest(OffsetTime.class,
+                (cfg, first) -> cfg.setTimeFormat(first ? "iso" : "xs"),
+                false, true);
+    }
+
+    @Test
+    public void testForYearMonth() throws Exception {
+        // Locale dependent formatters:
+        genericTest(YearMonth.class,
+                (cfg, first) -> cfg.setYearMonthFormat(first ? "yyyy-MM" : 
"yyyyMM"),
+                true, false);
+
+        // Locale independent formatters:
+        genericTest(YearMonth.class,
+                (cfg, first) -> cfg.setYearMonthFormat(first ? "iso" : "xs"),
+                false, false);
+    }
+
+    @Test
+    public void testForYear() throws Exception {
+        // Locale dependent formatters:
+        genericTest(Year.class,
+                (cfg, first) -> cfg.setYearFormat(first ? "yyyy" : "yy"),
+                true, false);
+
+        // Locale independent formatters:
+        genericTest(Year.class,
+                (cfg, first) -> cfg.setYearFormat(first ? "iso" : "xs"),
+                false, false);
+    }
+
+    private void genericTest(
+            Class<? extends Temporal> temporalClass,
+            SettingSetter settingSetter,
+            boolean localeDependent, boolean timeZoneDependent)
+            throws Exception {
+        Configuration cfg = new Configuration(Configuration.VERSION_2_3_31);
+        cfg.setLocale(Locale.GERMANY);
+        cfg.setTimeZone(DateUtil.UTC);
+        settingSetter.setSetting(cfg, true);
+
+        Environment env = new Template(null, "", cfg)
+                .createProcessingEnvironment(null, NullWriter.INSTANCE);
+
+        TemplateTemporalFormat lastFormat;
+        TemplateTemporalFormat newFormat;
+
+        lastFormat = env.getTemplateTemporalFormat(temporalClass);
+        // Assert that it keeps returning the same instance from cache:
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+
+        settingSetter.setSetting(env, true);
+        // Assert that the cache wasn't cleared when the setting was set to 
the same value again:
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+
+        env.setLocale(Locale.JAPAN); // Possibly clears non-reusable 
TemplateTemporalFormatCache field
+        newFormat = env.getTemplateTemporalFormat(temporalClass);
+        if (localeDependent) {
+            assertNotSame(lastFormat, newFormat);
+        } else {
+            assertSame(lastFormat, 
env.getTemplateTemporalFormat(temporalClass));
+        }
+        lastFormat = newFormat;
+
+        env.setLocale(Locale.JAPAN);
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+
+        env.setLocale(Locale.GERMANY); // Possibly clears non-reusable 
TemplateTemporalFormatCache field
+        env.setLocale(Locale.JAPAN);
+        // Assert that it restores the same instance from 
TemplateTemporalFormatCache.reusableXxx field:
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+
+        TimeZone otherTimeZone = TimeZone.getTimeZone("GMT+01");
+        env.setTimeZone(otherTimeZone); // Possibly clears non-reusable 
TemplateTemporalFormatCache field
+        newFormat = env.getTemplateTemporalFormat(temporalClass);
+        if (timeZoneDependent) {
+            assertNotSame(newFormat, lastFormat);
+            assertSame(newFormat, 
env.getTemplateTemporalFormat(temporalClass));
+        } else {
+            assertSame(newFormat, lastFormat);
+        }
+        lastFormat = newFormat;
+
+        env.setTimeZone(DateUtil.UTC); // Possibly clears non-reusable 
TemplateTemporalFormatCache field
+        env.setTimeZone(otherTimeZone);
+        // Assert that it restores the same instance from 
TemplateTemporalFormatCache.reusableXxx field:
+        assertSame(lastFormat, env.getTemplateTemporalFormat(temporalClass));
+
+        settingSetter.setSetting(env, false); // Clears even 
TemplateTemporalFormatCache.reusableXxx
+        newFormat = env.getTemplateTemporalFormat(temporalClass);
+        assertNotSame(lastFormat, newFormat);
+    }
+
+    @FunctionalInterface
+    interface SettingSetter {
+        void setSetting(Configurable configurable, boolean firstValue);
+    }
+
+}

Reply via email to