From a875ae09c5517e022fcdebcc2220663657743afe Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Sun, 28 Jun 2026 09:25:01 +0100 Subject: [PATCH] perf(engine): trim Date shim slot fan-out and binary-search timezone lookup RCA of the staging/sm/Date/dst-offset-caching timeouts (#829): profiling showed the cost is the JS Date shim's per-call machinery, not DST-offset recomputation (the offset code profiles at 0 samples and short-circuits on UTC hosts). Per maintainer direction, fix the genuinely-ineffective code with Date staying in JS. - Goccia.Shims.pas: the Date [[DateValue]] stays in a WeakMap (only non-observable slot store), but the hot accessors now do a single WeakMap.get with `=== undefined` as the brand check (a slot is always a number, never undefined), plus a numeric fast-path in __GocciaDateFromSingleArgument and a single-read #local/#utc. ~1.52x on `new Date(NaN); setTime; getTimezoneOffset`. - Goccia.Temporal.TimeZone.pas: IsSupportedCanonicalTimeZoneIdentifier scanned the ~446-entry available-zone list linearly on every ZonedDateTime canonicalization; the list is already CompareStr-sorted, so use a binary search. ~1.20x on named-zone construction. Does not clear the 20s deadline (needs ~15x; residual is inherent interpreter overhead). Refs #819, #829. Co-Authored-By: Claude Opus 4.8 --- source/units/Goccia.Shims.pas | 17 +++++++---- source/units/Goccia.Temporal.TimeZone.pas | 22 ++++++++++++--- tests/built-ins/Date/methods.js | 28 +++++++++++++++++++ .../Temporal/ZonedDateTime/constructor.js | 15 ++++++++++ 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/source/units/Goccia.Shims.pas b/source/units/Goccia.Shims.pas index 5c6b2f66..3ca6d386 100644 --- a/source/units/Goccia.Shims.pas +++ b/source/units/Goccia.Shims.pas @@ -438,7 +438,7 @@ implementation 'const __GocciaDateIsObject = (value: any): boolean =>'#10 + ' (typeof value === "object" && value !== null) || typeof value === "function";'#10 + 'const __GocciaDateHasSlot = (date: any): boolean =>'#10 + - ' __GocciaDateIsObject(date) && __GocciaDateSlots.has(date);'#10 + + ' __GocciaDateSlots.get(date) !== undefined;'#10 + 'const __GocciaDateOrdinaryToPrimitive = (object: any, hint: string): any => {'#10 + ' const first: string = hint === "string" ? "toString" : "valueOf";'#10 + ' const second: string = hint === "string" ? "valueOf" : "toString";'#10 + @@ -480,6 +480,7 @@ implementation ' (year < 0 ? "-" : "+") + __GocciaDatePad(year, 6);'#10 + 'const __GocciaDateOffsetString = (offset: string): string => offset.replace(":", "");'#10 + 'const __GocciaDateFromSingleArgument = (value: any): number => {'#10 + + ' if (typeof value === "number") return __GocciaDateTimeClip(value);'#10 + ' if (__GocciaDateHasSlot(value)) return __GocciaDateGetSlot(value);'#10 + ' const primitive: any = __GocciaDateToPrimitive(value);'#10 + ' return typeof primitive === "string" ? parseDateStringToEpoch(primitive) : __GocciaDateTimeClip(primitive);'#10 + @@ -494,12 +495,16 @@ implementation ' } catch (e) {}'#10 + ' return __GocciaDateClass.prototype;'#10 + '};'#10 + + '// A Date slot only ever stores a number (never undefined), so a missing'#10 + + '// entry reads back as undefined and doubles as the brand check. This keeps'#10 + + '// the hot accessors to a single WeakMap lookup instead of has()+get().'#10 + 'const __GocciaDateRequire = (date: any): void => {'#10 + - ' if (!__GocciaDateHasSlot(date)) throw new TypeError("Date object expected");'#10 + + ' if (__GocciaDateSlots.get(date) === undefined) throw new TypeError("Date object expected");'#10 + '};'#10 + 'const __GocciaDateGetSlot = (date: any): number => {'#10 + - ' __GocciaDateRequire(date);'#10 + - ' return __GocciaDateSlots.get(date);'#10 + + ' const value: any = __GocciaDateSlots.get(date);'#10 + + ' if (value === undefined) throw new TypeError("Date object expected");'#10 + + ' return value;'#10 + '};'#10 + 'const __GocciaDateSetSlot = (date: any, value: number): number => {'#10 + ' __GocciaDateSlots.set(date, value);'#10 + @@ -557,8 +562,8 @@ 'const __GocciaDateClass = class Date {'#10 + ' static #get(date: any): number { return __GocciaDateGetSlot(date); }'#10 + ' static #set(date: any, value: number): number { return __GocciaDateSetSlot(date, value); }'#10 + ' static #valid(date: any): boolean { return Number.isFinite(Date.#get(date)); }'#10 + - ' static #local(date: any): any { return Date.#valid(date) ? new Temporal.ZonedDateTime(BigInt(Date.#get(date)) * 1000000n, Temporal.Now.timeZoneId()) : null; }'#10 + - ' static #utc(date: any): any { return Date.#valid(date) ? new Temporal.ZonedDateTime(BigInt(Date.#get(date)) * 1000000n, "UTC") : null; }'#10 + + ' static #local(date: any): any { const ms: number = Date.#get(date); return Number.isFinite(ms) ? new Temporal.ZonedDateTime(BigInt(ms) * 1000000n, Temporal.Now.timeZoneId()) : null; }'#10 + + ' static #utc(date: any): any { const ms: number = Date.#get(date); return Number.isFinite(ms) ? new Temporal.ZonedDateTime(BigInt(ms) * 1000000n, "UTC") : null; }'#10 + ' getTime(): number { return __GocciaDateGetSlot(this); }'#10 + ' valueOf(): number { return __GocciaDateGetSlot(this); }'#10 + ' [Symbol.toPrimitive](hint: string): any {'#10 + diff --git a/source/units/Goccia.Temporal.TimeZone.pas b/source/units/Goccia.Temporal.TimeZone.pas index ff0fc670..ff6fcdaa 100644 --- a/source/units/Goccia.Temporal.TimeZone.pas +++ b/source/units/Goccia.Temporal.TimeZone.pas @@ -1093,12 +1093,26 @@ function IsUTCPrimaryEquivalent(const ATimeZone: string): Boolean; function IsSupportedCanonicalTimeZoneIdentifier(const ATimeZone: string): Boolean; var AvailableTimeZones: TTemporalTimeZoneIdentifierArray; - Index: Integer; + Low, High, Mid, Cmp: Integer; begin + // GetAvailablePrimaryTimeZoneIdentifiers returns a list sorted ascending by + // CompareStr (see SortTimeZoneIdentifiers). Probe it with a binary search + // instead of an O(n) scan: this runs on every ZonedDateTime time-zone + // canonicalization, and the available-zone list has several hundred entries. AvailableTimeZones := GetAvailablePrimaryTimeZoneIdentifiers; - for Index := 0 to Length(AvailableTimeZones) - 1 do - if AvailableTimeZones[Index] = ATimeZone then - Exit(True); + Low := 0; + High := Length(AvailableTimeZones) - 1; + while Low <= High do + begin + Mid := Low + (High - Low) div 2; + Cmp := CompareStr(AvailableTimeZones[Mid], ATimeZone); + if Cmp = 0 then + Exit(True) + else if Cmp < 0 then + Low := Mid + 1 + else + High := Mid - 1; + end; Result := False; end; diff --git a/tests/built-ins/Date/methods.js b/tests/built-ins/Date/methods.js index 2be51a39..7e8481e8 100644 --- a/tests/built-ins/Date/methods.js +++ b/tests/built-ins/Date/methods.js @@ -250,6 +250,34 @@ describe("Date methods", () => { } }); + test("slot-requiring methods reject a plain non-Date object receiver", () => { + const slotMethods = [ + "getTime", + "valueOf", + "getFullYear", + "getUTCFullYear", + "getTimezoneOffset", + "setTime", + "setFullYear", + "toISOString", + "toString", + ]; + + for (const method of slotMethods) { + const fn = Date.prototype[method]; + expect(() => fn.call({})).toThrow(TypeError); + expect(() => fn.call(Object.create(Date.prototype))).toThrow(TypeError); + } + }); + + test("constructor reads the time value from a Date argument and clips numbers", () => { + const source = new Date(1718451045123); + expect(new Date(source).getTime()).toBe(1718451045123); + expect(new Date(2000).getTime()).toBe(2000); + expect(Number.isNaN(new Date(NaN).getTime())).toBe(true); + expect(Number.isNaN(new Date(NaN).getTimezoneOffset())).toBe(true); + }); + test("toJSON remains generic for object receivers", () => { const receiver = { valueOf() { return 1; }, diff --git a/tests/built-ins/Temporal/ZonedDateTime/constructor.js b/tests/built-ins/Temporal/ZonedDateTime/constructor.js index 62fcb61e..7bcecc42 100644 --- a/tests/built-ins/Temporal/ZonedDateTime/constructor.js +++ b/tests/built-ins/Temporal/ZonedDateTime/constructor.js @@ -24,6 +24,21 @@ describe.runIf(isTemporal)("Temporal.ZonedDateTime constructor", () => { expect(zdt.calendarId).toBe("iso8601"); }); + test("canonical named time zones are accepted and preserved", () => { + const zones = [ + "Africa/Cairo", + "America/Los_Angeles", + "Asia/Tokyo", + "Europe/London", + "Pacific/Auckland", + "UTC", + ]; + for (const id of zones) { + expect(new Temporal.ZonedDateTime(0n, id).timeZoneId).toBe(id); + } + expect(() => new Temporal.ZonedDateTime(0n, "Not/AZone")).toThrow(RangeError); + }); + test("constructor stores non-ISO calendarId", () => { const zdt = new Temporal.ZonedDateTime(0n, "Europe/Madrid", "gregory"); expect(zdt.calendarId).toBe("gregory");