From 8283e83d1988370f1debb624c6dee6eb56b4b5d9 Mon Sep 17 00:00:00 2001 From: ar_tama Date: Thu, 6 Aug 2026 16:47:00 +0900 Subject: [PATCH 1/5] test: add failing test for Date subclassing in workflow VM Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama --- packages/core/src/vm/index.test.ts | 47 ++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 3f2ebbabeb..42402fe99a 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -49,6 +49,53 @@ 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 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 }); From 58bc5d0b5fcbb170400d8eae3725dcba80c33e6b Mon Sep 17 00:00:00 2001 From: ar_tama Date: Thu, 6 Aug 2026 16:51:01 +0900 Subject: [PATCH 2/5] 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 --- .changeset/date-subclass-vm.md | 5 +++++ packages/core/src/vm/index.ts | 23 ++++++++++++----------- 2 files changed, 17 insertions(+), 11 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..04eba902ba --- /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 is now a class that preserves `new.target`, so subclasses like `TZDate` from `@date-fns/tz` keep their identity, methods, and fields. diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index 0355abb884..8cb00be9aa 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -60,21 +60,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. A `class` (rather than a plain function) keeps `new.target` + // intact so user code can still subclass `Date` (e.g. `TZDate` from + // `@date-fns/tz`), and `extends` preserves the prototype chain and statics. 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 = class Date extends Date_ { + constructor(...args: any[]) { + if (args.length === 0) { + super(fixedTimestamp); + } else { + // @ts-expect-error - Args is `Date` constructor arguments + super(...args); + } } - // @ts-expect-error - Args is `Date` constructor arguments - return new Date_(...args); }; - (g as any).Date.prototype = Date_.prototype; - // Preserve static methods - Object.setPrototypeOf(g.Date, Date_); g.Date.now = () => fixedTimestamp; // Deterministic `crypto` using Proxy to avoid mutating global objects From 460104622722daec4e7283302d7ccaa4dfbe4acb Mon Sep 17 00:00:00 2001 From: ar_tama Date: Thu, 6 Aug 2026 16:57:48 +0900 Subject: [PATCH 3/5] test: add failing test for calling `Date()` without `new` Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama --- packages/core/src/vm/index.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 42402fe99a..5cc9bdef50 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -85,6 +85,17 @@ describe('createContext', () => { 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 }); From fa002a8442319120611d793cd682aa61fdc1d27a Mon Sep 17 00:00:00 2001 From: ar_tama Date: Thu, 6 Aug 2026 16:58:17 +0900 Subject: [PATCH 4/5] fix(core): keep `Date()` callable without `new` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/core/src/vm/index.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index 8cb00be9aa..a747075ac9 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -61,21 +61,25 @@ export function createContext(options: CreateContextOptions) { g.Math.random = rng; // Override `Date` constructor to return fixed time when called without - // arguments. A `class` (rather than a plain function) keeps `new.target` - // intact so user code can still subclass `Date` (e.g. `TZDate` from - // `@date-fns/tz`), and `extends` preserves the prototype chain and statics. + // 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 = class Date extends Date_ { - constructor(...args: any[]) { - if (args.length === 0) { - super(fixedTimestamp); - } else { - // @ts-expect-error - Args is `Date` constructor arguments - super(...args); - } + (g as any).Date = function Date(...args: any[]) { + if (new.target === undefined) { + return new Date_(fixedTimestamp).toString(); } + return Reflect.construct( + Date_, + args.length === 0 ? [fixedTimestamp] : args, + new.target + ); }; + (g as any).Date.prototype = Date_.prototype; + // Preserve static methods + Object.setPrototypeOf(g.Date, Date_); g.Date.now = () => fixedTimestamp; // Deterministic `crypto` using Proxy to avoid mutating global objects From 4c7cd3c20bc81c1edcb1588da9c22af5c5fcddfd Mon Sep 17 00:00:00 2001 From: ar_tama Date: Fri, 7 Aug 2026 09:33:04 +0900 Subject: [PATCH 5/5] chore: update changeset to match the final `Reflect.construct` implementation Co-Authored-By: Claude Fable 5 Signed-off-by: ar_tama --- .changeset/date-subclass-vm.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/date-subclass-vm.md b/.changeset/date-subclass-vm.md index 04eba902ba..25760a72eb 100644 --- a/.changeset/date-subclass-vm.md +++ b/.changeset/date-subclass-vm.md @@ -2,4 +2,4 @@ '@workflow/core': patch --- -Fix `Date` subclassing inside workflow functions. The deterministic `Date` override in the workflow VM is now a class that preserves `new.target`, so subclasses like `TZDate` from `@date-fns/tz` keep their identity, methods, and fields. +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.