Repository: moment/luxon
Pull request #1581 (2024-01-22 16:35:33)

Perf: Memoize digitsRegex
_This is part of a series of PRs based on performance work we have done to
improve a use-case involving parsing/formatting hundreds of thousands of dates
where luxon was the bottleneck._

Profiles reveal that a substantial portion of fromFormat time is spent in digitsRegex. It is only called from unitsForToken and it is always called with 11 suffixes to match different numbers of digits.

There are 21 numbering systems, so a fully expanded cache would hold 11*21 = 231 regexes.

This is related to https://github.com/moment/luxon/pull/1530 , but involves a much more restricted cache

Benchmark Comparison (`name | before | after | after/before`):
```
DateTime.fromFormat | 60,666 ±0.17% | 113,218 ±0.20% | 1.87x
DateTime.fromFormat with zone | 26,687 ±0.18% | 33,563 ±0.19% | 1.26x
```

linux-foundation-easycla[bot]:  <a href="https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1581/#/?version=2"><img src="https://s3.amazonaws.com/cla-project-logo-prod/cla-not-signed.svg" alt="CLA Not Signed" align="left" height="28" width="328"></a><br/><br /><ul><li><a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1581/#/?version=2' target='_blank'>:x:</a> - login: @schleyfox / name: Ben Hughes . The commit (3e363ee6a729735c8ec5cdf52827ff2840d4337d) is not authorized under a signed CLA. <a href='https://api.easycla.lfx.linuxfoundation.org/v2/repository-provider/github/sign/19772872/47118129/1581/#/?version=2' target='_blank'>Please click here to be authorized</a>. For further assistance with EasyCLA, <a href='https://jira.linuxfoundation.org/servicedesk/customer/portal/4' target='_blank'>please submit a support request ticket</a>.</li></ul>
schleyfox: /easycla

Patch:
diff --git a/src/datetime.js b/src/datetime.js
index 9472cb53..85533dda 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -373,8 +373,12 @@ function normalizeUnitWithLocalWeeks(unit) {
 // but doesn't do any validation, makes a bunch of assumptions about what units
 // are present, and so on.
 function quickDT(obj, opts) {
-  const zone = normalizeZone(opts.zone, Settings.defaultZone),
-    loc = Locale.fromObject(opts),
+  const zone = normalizeZone(opts.zone, Settings.defaultZone);
+  if (!zone.isValid) {
+    return DateTime.invalid(unsupportedZone(zone));
+  }
+
+  const loc = Locale.fromObject(opts),
     tsNow = Settings.now();
 
   let ts, o;
diff --git a/src/impl/digits.js b/src/impl/digits.js
index 6ac1bae0..c8c68af0 100644
--- a/src/impl/digits.js
+++ b/src/impl/digits.js
@@ -70,6 +70,21 @@ export function parseDigits(str) {
   }
 }
 
+// cache of {numberingSystem: {append: regex}}
+let digitRegexCache = {};
+export function resetDigitRegexCache() {
+  digitRegexCache = {};
+}
+
 export function digitRegex({ numberingSystem }, append = "") {
-  return new RegExp(`${numberingSystems[numberingSystem || "latn"]}${append}`);
+  const ns = numberingSystem || "latn";
+
+  if (!digitRegexCache[ns]) {
+    digitRegexCache[ns] = {};
+  }
+  if (!digitRegexCache[ns][append]) {
+    digitRegexCache[ns][append] = new RegExp(`${numberingSystems[ns]}${append}`);
+  }
+
+  return digitRegexCache[ns][append];
 }
diff --git a/src/settings.js b/src/settings.js
index 462e71e6..f109708d 100644
--- a/src/settings.js
+++ b/src/settings.js
@@ -4,6 +4,7 @@ import Locale from "./impl/locale.js";
 
 import { normalizeZone } from "./impl/zoneUtil.js";
 import { validateWeekSettings } from "./impl/util.js";
+import { resetDigitRegexCache } from "./impl/digits.js";
 
 let now = () => Date.now(),
   defaultZone = "system",
@@ -171,5 +172,6 @@ export default class Settings {
   static resetCaches() {
     Locale.resetCache();
     IANAZone.resetCache();
+    resetDigitRegexCache();
   }
 }


Test patch:
diff --git a/test/datetime/invalid.test.js b/test/datetime/invalid.test.js
index d13fb864..2fed5bbd 100644
--- a/test/datetime/invalid.test.js
+++ b/test/datetime/invalid.test.js
@@ -5,7 +5,11 @@ import { DateTime, Settings } from "../../src/luxon";
 const organic1 = DateTime.utc(2014, 13, 33),
   // not an actual Wednesday
   organic2 = DateTime.fromObject({ weekday: 3, year: 1982, month: 5, day: 25 }, { zone: "UTC" }),
-  organic3 = DateTime.fromObject({ year: 1982, month: 5, day: 25, hour: 27 });
+  organic3 = DateTime.fromObject({ year: 1982, month: 5, day: 25, hour: 27 }),
+  organic4 = DateTime.fromObject(
+    { year: 1982, month: 5, day: 25, hour: 2 },
+    { zone: "America/Lasers" }
+  );
 
 test("Explicitly invalid dates are invalid", () => {
   const dt = DateTime.invalid("just because", "seriously, just because");
@@ -22,14 +26,28 @@ test("Invalid creations are invalid", () => {
 
 test("invalid zones result in invalid dates", () => {
   expect(DateTime.now().setZone("America/Lasers").isValid).toBe(false);
+  expect(DateTime.now().setZone("America/Lasers").invalidReason).toBe("unsupported zone");
+
   expect(DateTime.local({ zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.local({ zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
+
+  expect(DateTime.local(1982, { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.local(1982, { zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
+
   expect(DateTime.fromJSDate(new Date(), { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.fromJSDate(new Date(), { zone: "America/Lasers" }).invalidReason).toBe(
+    "unsupported zone"
+  );
+
+  expect(DateTime.fromMillis(0, { zone: "America/Lasers" }).isValid).toBe(false);
+  expect(DateTime.fromMillis(0, { zone: "America/Lasers" }).invalidReason).toBe("unsupported zone");
 });
 
 test("Invalid DateTimes tell you why", () => {
   expect(organic1.invalidReason).toBe("unit out of range");
   expect(organic2.invalidReason).toBe("mismatched weekday");
   expect(organic3.invalidReason).toBe("unit out of range");
+  expect(organic4.invalidReason).toBe("unsupported zone");
 });
 
 test("Invalid DateTimes can provide an extended explanation", () => {
diff --git a/test/datetime/proto.test.js b/test/datetime/proto.test.js
index bbe132dc..71fba90f 100644
--- a/test/datetime/proto.test.js
+++ b/test/datetime/proto.test.js
@@ -4,6 +4,6 @@ import { DateTime } from "../../src/luxon";
 test("DateTime prototype properties should not throw when accessed", () => {
   const d = DateTime.now();
   expect(() =>
-    Object.getOwnPropertyNames(d.__proto__).forEach((name) => d.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(d)).forEach((name) => Object.getPrototypeOf(d)[name])
   ).not.toThrow();
 });
diff --git a/test/duration/proto.test.js b/test/duration/proto.test.js
index aba74c4f..40132ac9 100644
--- a/test/duration/proto.test.js
+++ b/test/duration/proto.test.js
@@ -3,6 +3,6 @@ import { Duration } from "../../src/luxon";
 test("Duration prototype properties should not throw when addressed", () => {
   const d = Duration.fromObject({ hours: 1 });
   expect(() =>
-    Object.getOwnPropertyNames(d.__proto__).forEach((name) => d.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(d)).forEach((name) => Object.getPrototypeOf(d)[name])
   ).not.toThrow();
 });
diff --git a/test/interval/proto.test.js b/test/interval/proto.test.js
index bcc3e9b3..ddf873ac 100644
--- a/test/interval/proto.test.js
+++ b/test/interval/proto.test.js
@@ -4,6 +4,6 @@ import { DateTime } from "../../src/luxon";
 test("Interval prototype properties should not throw when addressed", () => {
   const i = DateTime.fromISO("2018-01-01").until(DateTime.fromISO("2018-01-02"));
   expect(() =>
-    Object.getOwnPropertyNames(i.__proto__).forEach((name) => i.__proto__[name])
+    Object.getOwnPropertyNames(Object.getPrototypeOf(i)).forEach((name) => Object.getPrototypeOf(i)[name])
   ).not.toThrow();
 });