From df1beb78134bc2d5ceb9097a818f17aa453241e0 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 4 Feb 2026 23:47:34 -0800 Subject: [PATCH 1/4] fix: pass class as this context to custom serializer/deserializer methods When calling WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE static methods, use .call(ctor, value) and .call(cls, data) respectively to ensure the class is passed as 'this' context. This is necessary for serializers that access static properties or methods via 'this'. Adds test case to verify this behavior. --- packages/core/src/serialization.test.ts | 59 +++++++++++++++++++++++++ packages/core/src/serialization.ts | 4 +- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index 3f9f1f557d..6384c4ca4d 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -2217,6 +2217,65 @@ describe('custom class serialization', () => { expect(hydrated.created).toBeInstanceOf(Date); expect(hydrated.created.toISOString()).toBe('2025-01-01T00:00:00.000Z'); }); + + it('should pass class as this context to WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE', () => { + // This test verifies that serialize.call(ctor, value) and deserialize.call(cls, data) + // properly pass the class as `this` context, which is required when the serializer/deserializer + // needs to access static properties or methods on the class + + class Counter { + // Static property that the serializer uses via `this` + static serializedCount = 0; + static deserializedCount = 0; + + constructor(public value: number) {} + + static [WORKFLOW_SERIALIZE](this: typeof Counter, instance: Counter) { + // Access static property via `this` - this would fail if `this` is undefined + Counter.serializedCount++; + return { value: instance.value, serializedAt: Counter.serializedCount }; + } + + static [WORKFLOW_DESERIALIZE]( + this: typeof Counter, + data: { value: number; serializedAt: number } + ) { + // Access static property via `this` - this would fail if `this` is undefined + Counter.deserializedCount++; + return new Counter(data.value); + } + } + + // The classId is normally generated by the SWC compiler + (Counter as any).classId = 'test/Counter'; + + // Register the class for serialization/deserialization + registerSerializationClass('test/Counter', Counter); + + // Reset counters + Counter.serializedCount = 0; + Counter.deserializedCount = 0; + + // Serialize an instance - this should increment serializedCount via `this` + const counter = new Counter(42); + const serialized = dehydrateWorkflowArguments(counter, [], mockRunId); + + // Verify serialization used `this` correctly + expect(Counter.serializedCount).toBe(1); + + // Deserialize - this should increment deserializedCount via `this` + const hydrated = hydrateWorkflowArguments(serialized, globalThis); + + // Verify deserialization used `this` correctly + expect(Counter.deserializedCount).toBe(1); + expect(hydrated).toBeInstanceOf(Counter); + expect(hydrated.value).toBe(42); + + // Serialize another instance to verify counter increments + const counter2 = new Counter(100); + dehydrateWorkflowArguments(counter2, [], mockRunId); + expect(Counter.serializedCount).toBe(2); + }); }); describe('format prefix system', () => { diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 172275e902..a1b74e9fda 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -569,7 +569,7 @@ function getCommonReducers(global: Record = globalThis) { } // Serialize the instance using the custom serializer - const data = serialize(value); + const data = serialize.call(ctor, value); return { classId, data }; }, Set: (value) => value instanceof global.Set && Array.from(value), @@ -890,7 +890,7 @@ export function getCommonRevivers(global: Record = globalThis) { } // Deserialize the instance using the custom deserializer - return deserialize(data); + return deserialize.call(cls, data); }, Set: (value) => new global.Set(value), StepFunction: (value) => { From 3282f26809c806686870eaa0f74f50faf6ab7270 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 4 Feb 2026 23:56:08 -0800 Subject: [PATCH 2/4] fix: address PR review feedback - Fix test to actually use `this` instead of `Counter` directly, which properly validates the fix - Standardize variable naming: rename `ctor` to `cls` in the Instance reducer for consistency with the reviver --- packages/core/src/serialization.test.ts | 2 +- packages/core/src/serialization.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index 6384c4ca4d..bc071ab625 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -2219,7 +2219,7 @@ describe('custom class serialization', () => { }); it('should pass class as this context to WORKFLOW_SERIALIZE and WORKFLOW_DESERIALIZE', () => { - // This test verifies that serialize.call(ctor, value) and deserialize.call(cls, data) + // This test verifies that serialize.call(cls, value) and deserialize.call(cls, data) // properly pass the class as `this` context, which is required when the serializer/deserializer // needs to access static properties or methods on the class diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index a1b74e9fda..fb4ce0b284 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -551,25 +551,25 @@ function getCommonReducers(global: Record = globalThis) { Instance: (value) => { // Check if this is an instance of a class with custom serialization if (value === null || typeof value !== 'object') return false; - const ctor = value.constructor; - if (!ctor || typeof ctor !== 'function') return false; + const cls = value.constructor; + if (!cls || typeof cls !== 'function') return false; // Check if the class has a static WORKFLOW_SERIALIZE method - const serialize = ctor[WORKFLOW_SERIALIZE]; + const serialize = cls[WORKFLOW_SERIALIZE]; if (typeof serialize !== 'function') { return false; } // Get the classId from the static class property (set by SWC plugin) - const classId = ctor.classId; + const classId = cls.classId; if (typeof classId !== 'string') { throw new Error( - `Class "${ctor.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.` + `Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.` ); } // Serialize the instance using the custom serializer - const data = serialize.call(ctor, value); + const data = serialize.call(cls, value); return { classId, data }; }, Set: (value) => value instanceof global.Set && Array.from(value), From 5edac51e5cd117f58b8a099129aa461fda80e4c9 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Wed, 4 Feb 2026 23:57:47 -0800 Subject: [PATCH 3/4] Add changeset --- .changeset/shaky-seals-raise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shaky-seals-raise.md diff --git a/.changeset/shaky-seals-raise.md b/.changeset/shaky-seals-raise.md new file mode 100644 index 0000000000..163a982240 --- /dev/null +++ b/.changeset/shaky-seals-raise.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Pass class as `this` context to custom serializer/deserializer methods From bc1dca29a2545d06eb627065ec2b09f5b31cf083 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 5 Feb 2026 00:00:27 -0800 Subject: [PATCH 4/4] fix: use this in test serde functions with biome-ignore The test now properly uses `this.serializedCount` and `this.deserializedCount` to validate the fix. Added biome-ignore comments to prevent the linter from auto-fixing `this` back to the class name. --- packages/core/src/serialization.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/core/src/serialization.test.ts b/packages/core/src/serialization.test.ts index bc071ab625..00dea27000 100644 --- a/packages/core/src/serialization.test.ts +++ b/packages/core/src/serialization.test.ts @@ -2231,17 +2231,18 @@ describe('custom class serialization', () => { constructor(public value: number) {} static [WORKFLOW_SERIALIZE](this: typeof Counter, instance: Counter) { - // Access static property via `this` - this would fail if `this` is undefined - Counter.serializedCount++; - return { value: instance.value, serializedAt: Counter.serializedCount }; + // biome-ignore lint/complexity/noThisInStatic: intentionally testing `this` context binding + this.serializedCount++; + // biome-ignore lint/complexity/noThisInStatic: intentionally testing `this` context binding + return { value: instance.value, serializedAt: this.serializedCount }; } static [WORKFLOW_DESERIALIZE]( this: typeof Counter, data: { value: number; serializedAt: number } ) { - // Access static property via `this` - this would fail if `this` is undefined - Counter.deserializedCount++; + // biome-ignore lint/complexity/noThisInStatic: intentionally testing `this` context binding + this.deserializedCount++; return new Counter(data.value); } }