From b001f67d005822bd6049afd0b3b356db20cdc729 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:51:17 +0000 Subject: [PATCH] fix(core): preserve `new.target` in the deterministic `Date` override so `Date` subclasses work (#3372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: add failing test for Date subclassing in workflow VM Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama * fix(core): preserve `new.target` in the deterministic `Date` override so `Date` subclasses work in workflow functions The VM's `Date` override was a plain function, so `class X extends Date` lost the subclass identity: `super()` returned a fresh plain `Date` that became `this`, dropping the subclass's methods and fields. This silently broke `Date` subclasses like `TZDate` from `@date-fns/tz`. Using `class Date extends Date_` keeps `new.target` intact, and `extends` already wires up the prototype chain and statics, so the manual `prototype` assignment and `Object.setPrototypeOf` fix-ups are no longer needed. Determinism is unchanged: zero-arg construction still returns the fixed timestamp and `Date.now()` is still overridden. Fixes #3371 Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama * test: add failing test for calling `Date()` without `new` Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama * fix(core): keep `Date()` callable without `new` Use a plain function that branches on `new.target` and constructs via `Reflect.construct(Date_, args, new.target)` instead of a class: subclassing still works (`new.target` is forwarded), and calling `Date()` without `new` now matches the spec — arguments are ignored and the (fixed) time string is returned, where the previous override returned a `Date` object. Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama * chore: update changeset to match the final `Reflect.construct` implementation Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama --------- Signed-off-by: ar_tama Co-authored-by: Claude Fable 5 Signed-off-by: Makoto Arata --- .changeset/date-subclass-vm.md | 5 +++ packages/core/src/vm/index.test.ts | 58 ++++++++++++++++++++++++++++++ packages/core/src/vm/index.ts | 21 ++++++----- 3 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 .changeset/date-subclass-vm.md diff --git a/.changeset/date-subclass-vm.md b/.changeset/date-subclass-vm.md new file mode 100644 index 0000000000..25760a72eb --- /dev/null +++ b/.changeset/date-subclass-vm.md @@ -0,0 +1,5 @@ +--- +'@workflow/core': patch +--- + +Fix `Date` subclassing inside workflow functions. The deterministic `Date` override in the workflow VM now forwards `new.target` via `Reflect.construct`, so subclasses like `TZDate` from `@date-fns/tz` keep their identity, methods, and fields. Calling `Date()` without `new` now returns the (fixed) time string per spec, instead of a `Date` object. diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 8361fa63ca..abdada3e05 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -49,6 +49,64 @@ describe('createContext', () => { expect(result).toEqual(specificTime); }); + it('should support subclassing `Date`', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = vm.runInContext( + ` + class Sub extends Date { + constructor(...args) { + super(...args); + this.tag = 'sub'; + } + label() { + return 'sub'; + } + } + const sub = new Sub(2026, 6, 29); + const defaulted = new Sub(); + ({ + isSub: sub instanceof Sub, + isDate: sub instanceof Date, + keepsMethods: sub.label(), + keepsFields: sub.tag, + argsForwarded: sub.getTime() === new Date(2026, 6, 29).getTime(), + defaultedIsFixed: defaulted.getTime(), + }) + `, + context + ); + + expect(result.isSub).toBe(true); + expect(result.isDate).toBe(true); + expect(result.keepsMethods).toBe('sub'); + expect(result.keepsFields).toBe('sub'); + expect(result.argsForwarded).toBe(true); + expect(result.defaultedIsFixed).toEqual(fixedTimestamp); + }); + + it('should keep `Date()` callable without `new`, returning the fixed time string', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = vm.runInContext('Date()', context); + + expect(result).toBeTypeOf('string'); + expect(result).toEqual(vm.runInContext('new Date().toString()', context)); + // Per spec, `Date()` as a function ignores its arguments + expect(vm.runInContext('Date(2000, 0, 1)', context)).toEqual(result); + }); + + it('should preserve `Date` static methods', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect( + vm.runInContext("Date.parse('2000-01-01T00:00:00.000Z')", context) + ).toEqual(946684800000); + expect(vm.runInContext('Date.UTC(2000, 0, 1)', context)).toEqual( + 946684800000 + ); + }); + it('should have deterministic `crypto.getRandomValues()`', () => { const { context } = createContext({ seed, fixedTimestamp }); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index a0df4a75c1..92f856848e 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -27,17 +27,22 @@ export function createContext(options: CreateContextOptions) { // Deterministic `Math.random()` g.Math.random = rng; - // Override `Date` constructor to return fixed time when called without arguments + // Override `Date` constructor to return fixed time when called without + // arguments. Constructing through `Reflect.construct` with `new.target` + // keeps subclassing intact (e.g. `TZDate` from `@date-fns/tz`), while a + // plain function (rather than a `class`) keeps `Date()` callable without + // `new`, which per spec ignores its arguments and returns the time string. const Date_ = g.Date; // biome-ignore lint/suspicious/noShadowRestrictedNames: We're shadowing the global `Date` property to make it deterministic. - (g as any).Date = function Date( - ...args: Parameters<(typeof globalThis)['Date']>[] - ) { - if (args.length === 0) { - return new Date_(fixedTimestamp); + (g as any).Date = function Date(...args: any[]) { + if (new.target === undefined) { + return new Date_(fixedTimestamp).toString(); } - // @ts-expect-error - Args is `Date` constructor arguments - return new Date_(...args); + return Reflect.construct( + Date_, + args.length === 0 ? [fixedTimestamp] : args, + new.target + ); }; (g as any).Date.prototype = Date_.prototype; // Preserve static methods