From b95a36a3e2d64d8639ccf34dfe2445e164479db7 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 2 Jun 2026 21:43:55 +0200 Subject: [PATCH 1/4] fix(intl): delegate locale prototype methods Implement Intl-aware Number, Array, and Date toLocale* prototype methods via the default shim layer. Closes #605 --- docs/built-ins.md | 3 +- source/units/Goccia.Shims.pas | 60 +++++++++++++++++++ .../Array/prototype/toLocaleString.js | 43 +++++++++++++ .../Date/prototype/toLocaleDateString.js | 27 +++++++++ .../Date/prototype/toLocaleString.js | 30 ++++++++++ .../Date/prototype/toLocaleTimeString.js | 27 +++++++++ .../Number/prototype/toLocaleString.js | 28 +++++++++ 7 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 tests/built-ins/Array/prototype/toLocaleString.js create mode 100644 tests/built-ins/Date/prototype/toLocaleDateString.js create mode 100644 tests/built-ins/Date/prototype/toLocaleString.js create mode 100644 tests/built-ins/Date/prototype/toLocaleTimeString.js create mode 100644 tests/built-ins/Number/prototype/toLocaleString.js diff --git a/docs/built-ins.md b/docs/built-ins.md index 5b950fd2..93a89b48 100644 --- a/docs/built-ins.md +++ b/docs/built-ins.md @@ -83,7 +83,7 @@ Implements the [ECMAScript Object](https://developer.mozilla.org/en-US/docs/Web/ ### Array (`Goccia.Builtins.GlobalArray.pas`) -Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). **Not implemented:** `Array.prototype.reduceRight`, `Array.prototype.toLocaleString`. +Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array). **Not implemented:** `Array.prototype.reduceRight`. **Static methods:** @@ -115,6 +115,7 @@ Implements the [ECMAScript Array](https://developer.mozilla.org/en-US/docs/Web/J | `includes(value, fromIndex?)` | Check if array contains value | | `join(separator?)` | Join elements into string | | `toString()` | Returns comma-separated string (delegates to `join()`) | +| `toLocaleString(locales?, options?)` | Join element locale strings | | `concat(...arrays)` | Concatenate arrays | | `slice(start?, end?)` | Extract a section | | `push(...items)` | Add to end (mutating) | diff --git a/source/units/Goccia.Shims.pas b/source/units/Goccia.Shims.pas index ee58aad4..b5a328ad 100644 --- a/source/units/Goccia.Shims.pas +++ b/source/units/Goccia.Shims.pas @@ -180,7 +180,36 @@ implementation ' return Math.trunc(epochMilliseconds);'#10 + ' } catch (e) { return NaN; }'#10 + '};'#10 + + 'const __GocciaShimNumberFormat: any = Intl.NumberFormat;'#10 + + 'const __GocciaShimDateTimeFormat: any = Intl.DateTimeFormat;'#10 + 'export const Date = class Date {'#10 + + ' static {'#10 + + ' const numberLocale = {'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' const value: number = Number.prototype.valueOf.call(this);'#10 + + ' return new __GocciaShimNumberFormat(args[0], args[1]).format(value);'#10 + + ' }'#10 + + ' }.toLocaleString;'#10 + + ' const arrayLocale = {'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' const array: any = Object(this);'#10 + + ' const length: number = Number(array.length);'#10 + + ' const len: number = !Number.isFinite(length) || length <= 0 ? 0 : Math.trunc(length);'#10 + + ' return Array.from({ length: len }, (_: any, index: number): string => {'#10 + + ' const nextElement: any = array[index];'#10 + + ' if (nextElement !== undefined && nextElement !== null) {'#10 + + ' const method: any = Object(nextElement).toLocaleString;'#10 + + ' if (typeof method !== "function")'#10 + + ' throw new TypeError("Array.prototype.toLocaleString element toLocaleString is not a function");'#10 + + ' return String(method.call(nextElement, args[0], args[1]));'#10 + + ' }'#10 + + ' return "";'#10 + + ' }).join(",");'#10 + + ' }'#10 + + ' }.toLocaleString;'#10 + + ' Object.defineProperty(Number.prototype, "toLocaleString", { value: numberLocale, writable: true, configurable: true });'#10 + + ' Object.defineProperty(Array.prototype, "toLocaleString", { value: arrayLocale, writable: true, configurable: true });'#10 + + ' }'#10 + ' #ms;'#10 + ' static now(): number { return Temporal.Now.instant().epochMilliseconds; }'#10 + ' static parse(str: string): number {'#10 + @@ -244,6 +273,37 @@ 'export const Date = class Date {'#10 + ' return days[z.dayOfWeek % 7] + " " + months[z.month - 1] + " " + pad(z.day) + " " +'#10 + ' String(z.year) + " " + pad(z.hour) + ":" + pad(z.minute) + ":" + pad(z.second) + " GMT" + z.offset;'#10 + ' }'#10 + + ' #localeOptions(options: any, needDate: boolean, needTime: boolean): any {'#10 + + ' const out: any = {};'#10 + + ' if (options !== undefined) {'#10 + + ' ["localeMatcher", "calendar", "numberingSystem", "timeZone", "hour12", "hourCycle", "formatMatcher",'#10 + + ' "weekday", "era", "year", "month", "day", "dayPeriod", "hour", "minute", "second",'#10 + + ' "fractionalSecondDigits", "timeZoneName", "dateStyle", "timeStyle"].forEach((key: string): void => {'#10 + + ' const value: any = options[key];'#10 + + ' if (value !== undefined) out[key] = value;'#10 + + ' });'#10 + + ' }'#10 + + ' const hasDate = out.weekday !== undefined || out.year !== undefined || out.month !== undefined || out.day !== undefined || out.dateStyle !== undefined;'#10 + + ' const hasTime = out.dayPeriod !== undefined || out.hour !== undefined || out.minute !== undefined || out.second !== undefined || out.fractionalSecondDigits !== undefined || out.timeStyle !== undefined;'#10 + + ' if (needDate && !hasDate) { out.year = "numeric"; out.month = "numeric"; out.day = "numeric"; }'#10 + + ' if (needTime && !hasTime) { out.hour = "numeric"; out.minute = "numeric"; out.second = "numeric"; }'#10 + + ' return out;'#10 + + ' }'#10 + + ' toLocaleString(...args: any[]): string {'#10 + + ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleString called on non-Date");'#10 + + ' if (!this.#valid()) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, true)).format(this.#ms);'#10 + + ' }'#10 + + ' toLocaleDateString(...args: any[]): string {'#10 + + ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleDateString called on non-Date");'#10 + + ' if (!this.#valid()) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, false)).format(this.#ms);'#10 + + ' }'#10 + + ' toLocaleTimeString(...args: any[]): string {'#10 + + ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleTimeString called on non-Date");'#10 + + ' if (!this.#valid()) return "Invalid Date";'#10 + + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], false, true)).format(this.#ms);'#10 + + ' }'#10 + '};' ), ( // ES2026 §20.1.3.2 — legacy hasOwnProperty via Object.hasOwn diff --git a/tests/built-ins/Array/prototype/toLocaleString.js b/tests/built-ins/Array/prototype/toLocaleString.js new file mode 100644 index 00000000..f987755c --- /dev/null +++ b/tests/built-ins/Array/prototype/toLocaleString.js @@ -0,0 +1,43 @@ +/*--- +description: Array.prototype.toLocaleString delegates to each element +features: [Intl, Array.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe("Array.prototype.toLocaleString", () => { + test("exists on Array.prototype", () => { + expect(typeof Array.prototype.toLocaleString).toBe("function"); + }); + + test("invokes each element toLocaleString with locales and options", () => { + const calls = []; + const options = { style: "currency", currency: "EUR" }; + const item = { + toLocaleString(locales, receivedOptions) { + calls.push([locales, receivedOptions]); + return "item"; + }, + }; + + expect([item, null, undefined, item].toLocaleString("de-DE", options)).toBe("item,,,item"); + expect(calls.length).toBe(2); + expect(calls[0][0]).toBe("de-DE"); + expect(calls[0][1]).toBe(options); + expect(calls[1][0]).toBe("de-DE"); + expect(calls[1][1]).toBe(options); + }); + + test("throws when an element toLocaleString is not callable", () => { + expect(() => [{ toLocaleString: 1 }].toLocaleString()).toThrow(TypeError); + }); +}); + +describe.runIf(isIntl)("Array.prototype.toLocaleString Intl elements", () => { + test("formats number elements through Number.prototype.toLocaleString", () => { + const options = { style: "currency", currency: "EUR" }; + const expected = new Intl.NumberFormat("de-DE", options).format(1234.5); + + expect([1234.5].toLocaleString("de-DE", options)).toBe(expected); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleDateString.js b/tests/built-ins/Date/prototype/toLocaleDateString.js new file mode 100644 index 00000000..a46aa811 --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleDateString.js @@ -0,0 +1,27 @@ +/*--- +description: Date.prototype.toLocaleDateString delegates to Intl.DateTimeFormat date defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleDateString", () => { + test("formats with date defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleDateString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric", + }).format(date)); + }); + + test("preserves explicit date options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", year: "numeric", month: "long" }; + + expect(date.toLocaleDateString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleString.js b/tests/built-ins/Date/prototype/toLocaleString.js new file mode 100644 index 00000000..82cb7331 --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleString.js @@ -0,0 +1,30 @@ +/*--- +description: Date.prototype.toLocaleString delegates to Intl.DateTimeFormat date and time defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleString", () => { + test("formats with date and time defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }).format(date)); + }); + + test("preserves explicit date and time options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }; + + expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); +}); diff --git a/tests/built-ins/Date/prototype/toLocaleTimeString.js b/tests/built-ins/Date/prototype/toLocaleTimeString.js new file mode 100644 index 00000000..3a6d79bc --- /dev/null +++ b/tests/built-ins/Date/prototype/toLocaleTimeString.js @@ -0,0 +1,27 @@ +/*--- +description: Date.prototype.toLocaleTimeString delegates to Intl.DateTimeFormat time defaults +features: [Intl, Date] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Date.prototype.toLocaleTimeString", () => { + test("formats with time defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC" }; + + expect(date.toLocaleTimeString("de-DE", options)).toBe(new Intl.DateTimeFormat("de-DE", { + timeZone: "UTC", + hour: "numeric", + minute: "numeric", + second: "numeric", + }).format(date)); + }); + + test("preserves explicit time options", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", hour: "2-digit", minute: "2-digit" }; + + expect(date.toLocaleTimeString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); +}); diff --git a/tests/built-ins/Number/prototype/toLocaleString.js b/tests/built-ins/Number/prototype/toLocaleString.js new file mode 100644 index 00000000..3e128d51 --- /dev/null +++ b/tests/built-ins/Number/prototype/toLocaleString.js @@ -0,0 +1,28 @@ +/*--- +description: Number.prototype.toLocaleString delegates to Intl.NumberFormat +features: [Intl, Number.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe.runIf(isIntl)("Number.prototype.toLocaleString", () => { + test("formats with the requested locale", () => { + const value = 1234567.89; + + expect(value.toLocaleString("de-DE")).toBe(new Intl.NumberFormat("de-DE").format(value)); + expect(value.toLocaleString("en-US")).toBe(new Intl.NumberFormat("en-US").format(value)); + }); + + test("passes formatting options to Intl.NumberFormat", () => { + const options = { style: "currency", currency: "EUR" }; + const value = 1234.5; + + expect(value.toLocaleString("de-DE", options)).toBe(new Intl.NumberFormat("de-DE", options).format(value)); + }); + + test("works with Number objects", () => { + const value = new Number(42); + + expect(value.toLocaleString("en-US")).toBe(new Intl.NumberFormat("en-US").format(42)); + }); +}); From 99f33fcb311a7a869e9bf3e3fd7d80c0b16c8cf5 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Tue, 2 Jun 2026 22:20:13 +0200 Subject: [PATCH 2/4] fix(intl): harden locale prototype methods --- source/units/Goccia.Shims.pas | 21 ++++++++++++------- .../Array/prototype/toLocaleString.js | 15 +++++++++++++ .../Date/prototype/toLocaleDateString.js | 6 ++++++ .../Date/prototype/toLocaleString.js | 6 ++++++ .../Date/prototype/toLocaleTimeString.js | 6 ++++++ 5 files changed, 46 insertions(+), 8 deletions(-) diff --git a/source/units/Goccia.Shims.pas b/source/units/Goccia.Shims.pas index b5a328ad..95441b8a 100644 --- a/source/units/Goccia.Shims.pas +++ b/source/units/Goccia.Shims.pas @@ -195,7 +195,7 @@ 'export const Date = class Date {'#10 + ' const array: any = Object(this);'#10 + ' const length: number = Number(array.length);'#10 + ' const len: number = !Number.isFinite(length) || length <= 0 ? 0 : Math.trunc(length);'#10 + - ' return Array.from({ length: len }, (_: any, index: number): string => {'#10 + + ' const elementString = (index: number): string => {'#10 + ' const nextElement: any = array[index];'#10 + ' if (nextElement !== undefined && nextElement !== null) {'#10 + ' const method: any = Object(nextElement).toLocaleString;'#10 + @@ -204,7 +204,9 @@ 'export const Date = class Date {'#10 + ' return String(method.call(nextElement, args[0], args[1]));'#10 + ' }'#10 + ' return "";'#10 + - ' }).join(",");'#10 + + ' };'#10 + + ' const build = (index: number, result: string): string => index >= len ? result : build(index + 1, result + (index > 0 ? "," : "") + elementString(index));'#10 + + ' return build(0, "");'#10 + ' }'#10 + ' }.toLocaleString;'#10 + ' Object.defineProperty(Number.prototype, "toLocaleString", { value: numberLocale, writable: true, configurable: true });'#10 + @@ -290,18 +292,21 @@ 'export const Date = class Date {'#10 + ' return out;'#10 + ' }'#10 + ' toLocaleString(...args: any[]): string {'#10 + - ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleString called on non-Date");'#10 + - ' if (!this.#valid()) return "Invalid Date";'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, true)).format(this.#ms);'#10 + ' }'#10 + ' toLocaleDateString(...args: any[]): string {'#10 + - ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleDateString called on non-Date");'#10 + - ' if (!this.#valid()) return "Invalid Date";'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleDateString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], true, false)).format(this.#ms);'#10 + ' }'#10 + ' toLocaleTimeString(...args: any[]): string {'#10 + - ' if (!(this instanceof Date)) throw new TypeError("Date.prototype.toLocaleTimeString called on non-Date");'#10 + - ' if (!this.#valid()) return "Invalid Date";'#10 + + ' let valid: boolean;'#10 + + ' try { valid = this.#valid(); } catch (e) { throw new TypeError("Date.prototype.toLocaleTimeString called on non-Date"); }'#10 + + ' if (!valid) return "Invalid Date";'#10 + ' return new __GocciaShimDateTimeFormat(args[0], this.#localeOptions(args[1], false, true)).format(this.#ms);'#10 + ' }'#10 + '};' diff --git a/tests/built-ins/Array/prototype/toLocaleString.js b/tests/built-ins/Array/prototype/toLocaleString.js index f987755c..e524aa03 100644 --- a/tests/built-ins/Array/prototype/toLocaleString.js +++ b/tests/built-ins/Array/prototype/toLocaleString.js @@ -31,6 +31,21 @@ describe("Array.prototype.toLocaleString", () => { test("throws when an element toLocaleString is not callable", () => { expect(() => [{ toLocaleString: 1 }].toLocaleString()).toThrow(TypeError); }); + + test("does not delegate to mutable Array helpers", () => { + const originalFrom = Array.from; + const originalJoin = Array.prototype.join; + + try { + Array.from = () => ["tainted"]; + Array.prototype.join = () => "tainted"; + + expect([1, 2].toLocaleString()).toBe("1,2"); + } finally { + Array.from = originalFrom; + Array.prototype.join = originalJoin; + } + }); }); describe.runIf(isIntl)("Array.prototype.toLocaleString Intl elements", () => { diff --git a/tests/built-ins/Date/prototype/toLocaleDateString.js b/tests/built-ins/Date/prototype/toLocaleDateString.js index a46aa811..9e6aa2de 100644 --- a/tests/built-ins/Date/prototype/toLocaleDateString.js +++ b/tests/built-ins/Date/prototype/toLocaleDateString.js @@ -24,4 +24,10 @@ describe.runIf(isIntl)("Date.prototype.toLocaleDateString", () => { expect(date.toLocaleDateString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleDateString.call(fakeDate)).toThrow(TypeError); + }); }); diff --git a/tests/built-ins/Date/prototype/toLocaleString.js b/tests/built-ins/Date/prototype/toLocaleString.js index 82cb7331..3e089eb8 100644 --- a/tests/built-ins/Date/prototype/toLocaleString.js +++ b/tests/built-ins/Date/prototype/toLocaleString.js @@ -27,4 +27,10 @@ describe.runIf(isIntl)("Date.prototype.toLocaleString", () => { expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleString.call(fakeDate)).toThrow(TypeError); + }); }); diff --git a/tests/built-ins/Date/prototype/toLocaleTimeString.js b/tests/built-ins/Date/prototype/toLocaleTimeString.js index 3a6d79bc..151605c5 100644 --- a/tests/built-ins/Date/prototype/toLocaleTimeString.js +++ b/tests/built-ins/Date/prototype/toLocaleTimeString.js @@ -24,4 +24,10 @@ describe.runIf(isIntl)("Date.prototype.toLocaleTimeString", () => { expect(date.toLocaleTimeString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); }); + + test("throws TypeError for fake Date receiver", () => { + const fakeDate = Object.create(Date.prototype); + + expect(() => Date.prototype.toLocaleTimeString.call(fakeDate)).toThrow(TypeError); + }); }); From 18b85770690ad14f18ccae23f9f1d31c058e98cd Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Wed, 3 Jun 2026 17:02:59 +0200 Subject: [PATCH 3/4] fix(intl): delegate typed array locale strings --- .../units/Goccia.Values.TypedArrayValue.pas | 44 +++++++++++++++++++ .../TypedArray/prototype/toLocaleString.js | 34 ++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 tests/built-ins/TypedArray/prototype/toLocaleString.js diff --git a/source/units/Goccia.Values.TypedArrayValue.pas b/source/units/Goccia.Values.TypedArrayValue.pas index 9ae89f59..4f2c11c6 100644 --- a/source/units/Goccia.Values.TypedArrayValue.pas +++ b/source/units/Goccia.Values.TypedArrayValue.pas @@ -116,6 +116,7 @@ TGocciaTypedArrayValue = class(TGocciaInstanceValue) function TypedArrayReduce(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayReduceRight(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayJoin(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; + function TypedArrayToLocaleString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToReversed(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; function TypedArrayToSorted(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; @@ -846,6 +847,7 @@ procedure TGocciaTypedArrayValue.InitializePrototype; Members.AddMethod(TypedArrayReduce, 2, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayReduceRight, 2, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayJoin, 1, gmkPrototypeMethod, [gmfNoFunctionPrototype]); + Members.AddNamedMethod(PROP_TO_LOCALE_STRING, TypedArrayToLocaleString, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToString, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToReversed, 0, gmkPrototypeMethod, [gmfNoFunctionPrototype]); Members.AddMethod(TypedArrayToSorted, 1, gmkPrototypeMethod, [gmfNoFunctionPrototype]); @@ -2129,6 +2131,48 @@ function TGocciaTypedArrayValue.TypedArrayJoin(const AArgs: TGocciaArgumentsColl Result := TGocciaStringLiteralValue.Create(S); end; +// ECMA-402 §17.1.6 %TypedArray%.prototype.toLocaleString([locales [, options]]) +function TGocciaTypedArrayValue.TypedArrayToLocaleString(const AArgs: TGocciaArgumentsCollection; + const AThisValue: TGocciaValue): TGocciaValue; +var + TA: TGocciaTypedArrayValue; + I, Len: Integer; + S: string; + Element, Method, Formatted: TGocciaValue; + ElementObject: TGocciaObjectValue; + CallArgs: TGocciaArgumentsCollection; +begin + TA := RequireAttachedTypedArray(AThisValue, '%TypedArray%.prototype.toLocaleString'); + Len := TA.FLength; + S := ''; + for I := 0 to Len - 1 do + begin + if I > 0 then S := S + ','; + Element := TA.GetElementAsValue(I); + ElementObject := ToObject(Element); + Method := ElementObject.GetPropertyWithContext(PROP_TO_LOCALE_STRING, Element); + if (Method = nil) or not Method.IsCallable then + ThrowTypeError('TypedArray.prototype.toLocaleString element toLocaleString is not a function'); + + CallArgs := TGocciaArgumentsCollection.CreateWithCapacity(2); + try + if AArgs.Length > 0 then + CallArgs.Add(AArgs.GetElement(0)) + else + CallArgs.Add(TGocciaUndefinedLiteralValue.UndefinedValue); + if AArgs.Length > 1 then + CallArgs.Add(AArgs.GetElement(1)) + else + CallArgs.Add(TGocciaUndefinedLiteralValue.UndefinedValue); + Formatted := InvokeCallable(Method, CallArgs, Element); + finally + CallArgs.Free; + end; + S := S + Formatted.ToStringLiteral.Value; + end; + Result := TGocciaStringLiteralValue.Create(S); +end; + // ES2026 §23.2.3.33 %TypedArray%.prototype.toString() function TGocciaTypedArrayValue.TypedArrayToString(const AArgs: TGocciaArgumentsCollection; const AThisValue: TGocciaValue): TGocciaValue; begin diff --git a/tests/built-ins/TypedArray/prototype/toLocaleString.js b/tests/built-ins/TypedArray/prototype/toLocaleString.js new file mode 100644 index 00000000..ec1b55db --- /dev/null +++ b/tests/built-ins/TypedArray/prototype/toLocaleString.js @@ -0,0 +1,34 @@ +/*--- +description: TypedArray.prototype.toLocaleString delegates to each element +features: [Intl, TypedArray.prototype.toLocaleString] +---*/ + +const isIntl = typeof Intl !== "undefined"; + +describe("%TypedArray%.prototype.toLocaleString", () => { + test("invokes number element toLocaleString with locales and options", () => { + const original = Number.prototype.toLocaleString; + const options = { marker: "yes" }; + + try { + Number.prototype.toLocaleString = { + method(locales, receivedOptions) { + return "number:" + locales + ":" + receivedOptions.marker; + }, + }.method; + + expect(new Uint8Array([0]).toLocaleString("xx", options)).toBe("number:xx:yes"); + } finally { + Number.prototype.toLocaleString = original; + } + }); +}); + +describe.runIf(isIntl)("%TypedArray%.prototype.toLocaleString Intl elements", () => { + test("formats number elements through Number.prototype.toLocaleString", () => { + const options = { minimumFractionDigits: 3 }; + const expected = new Intl.NumberFormat("th-u-nu-thai", options).format(0); + + expect(new Uint8Array([0]).toLocaleString("th-u-nu-thai", options)).toBe(expected); + }); +}); From c9df6b72c62fd414a56a10eb1b3f0408d014cc59 Mon Sep 17 00:00:00 2001 From: Johannes Stein Date: Wed, 3 Jun 2026 19:05:51 +0200 Subject: [PATCH 4/4] fix(intl): preserve Date locale style options --- source/units/Goccia.Shims.pas | 5 +++-- tests/built-ins/Date/prototype/toLocaleString.js | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/source/units/Goccia.Shims.pas b/source/units/Goccia.Shims.pas index 95441b8a..c6856226 100644 --- a/source/units/Goccia.Shims.pas +++ b/source/units/Goccia.Shims.pas @@ -287,8 +287,9 @@ 'export const Date = class Date {'#10 + ' }'#10 + ' const hasDate = out.weekday !== undefined || out.year !== undefined || out.month !== undefined || out.day !== undefined || out.dateStyle !== undefined;'#10 + ' const hasTime = out.dayPeriod !== undefined || out.hour !== undefined || out.minute !== undefined || out.second !== undefined || out.fractionalSecondDigits !== undefined || out.timeStyle !== undefined;'#10 + - ' if (needDate && !hasDate) { out.year = "numeric"; out.month = "numeric"; out.day = "numeric"; }'#10 + - ' if (needTime && !hasTime) { out.hour = "numeric"; out.minute = "numeric"; out.second = "numeric"; }'#10 + + ' const needDefaults = !hasDate && !hasTime;'#10 + + ' if (needDefaults && needDate) { out.year = "numeric"; out.month = "numeric"; out.day = "numeric"; }'#10 + + ' if (needDefaults && needTime) { out.hour = "numeric"; out.minute = "numeric"; out.second = "numeric"; }'#10 + ' return out;'#10 + ' }'#10 + ' toLocaleString(...args: any[]): string {'#10 + diff --git a/tests/built-ins/Date/prototype/toLocaleString.js b/tests/built-ins/Date/prototype/toLocaleString.js index 3e089eb8..a2168284 100644 --- a/tests/built-ins/Date/prototype/toLocaleString.js +++ b/tests/built-ins/Date/prototype/toLocaleString.js @@ -28,6 +28,13 @@ describe.runIf(isIntl)("Date.prototype.toLocaleString", () => { expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); }); + test("preserves style-only options without adding component defaults", () => { + const date = new Date(Date.UTC(2026, 0, 1, 15, 30, 45)); + const options = { timeZone: "UTC", dateStyle: "short" }; + + expect(date.toLocaleString("en-US", options)).toBe(new Intl.DateTimeFormat("en-US", options).format(date)); + }); + test("throws TypeError for fake Date receiver", () => { const fakeDate = Object.create(Date.prototype);