[swc-plugin] Capture lexical this for nested arrow step functions#1935
[swc-plugin] Capture lexical this for nested arrow step functions#1935TooTallNate merged 6 commits intomainfrom
this for nested arrow step functions#1935Conversation
When a nested arrow `"use step"` references the enclosing function/method's `this`, plumb that `this` through the workflow runtime so the step body sees the correct receiver. - Workflow mode wraps the step proxy with `.bind(this)`, so invoking the proxy captures the caller's `this` as `thisVal` on the queue item. - Step mode hoists the body as a regular `function` (not an arrow) so the runtime's `stepFn.apply(thisVal, args)` rebinds `this` inside the hoisted body. Detection only fires for arrows, since arrows inherit `this` lexically. Nested non-arrow functions/methods/getters/setters introduce their own `this`, so the detector stops at those boundaries. The runtime already supported `thisVal` for instance-method steps; this PR is purely a compiler change to feed the existing pipeline. Caveat: capture works at runtime only when the captured value is serializable across the workflow->step boundary (i.e. the enclosing class implements `WORKFLOW_SERIALIZE`/`WORKFLOW_DESERIALIZE`). Refs #1865
🦋 Changeset detectedLatest commit: a19805f The changes in this PR will be included in the next version bump. This PR includes changesets to release 19 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
|
There was a problem hiding this comment.
Pull request overview
Updates @workflow/swc-plugin to correctly preserve lexical this for nested arrow "use step" functions (common in “method returns object literal with arrow steps” patterns), by detecting this usage, binding step proxies in workflow mode, and hoisting step bodies as regular functions in step mode so runtime apply(thisVal, args) rebinding works.
Changes:
- Add lexical-
thisdetection for nested arrow step bodies and thread areferences_lexical_thisflag through nested-step tracking. - Emit
.bind(this)on workflow-mode step proxies when lexicalthisis referenced; adjust step-mode hoisting to emitfunctioninstead of arrow for those steps. - Add SWC fixture coverage + a core runtime test; document the new behavior and ship a changeset.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/swc-plugin-workflow/transform/src/lib.rs | Implements lexical-this detection, proxy binding, and step-mode hoist changes. |
| packages/swc-plugin-workflow/spec.md | Documents lexical this capture behavior and updates notes about this in steps. |
| packages/core/src/step.test.ts | Adds runtime test ensuring .bind(this) captures thisVal onto the invocation queue item. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this/input.js | New fixture input covering nested arrow step referencing this in a method. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this/output-workflow.js | Expected workflow-mode output showing .bind(this) on the step proxy. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this/output-step.js | Expected step-mode output showing hoisting as async function using this. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this-var-decl/input.js | New fixture input for var-declarator arrow step capturing this. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this-var-decl/output-workflow.js | Expected workflow-mode output with bound proxy for var-declarator case. |
| packages/swc-plugin-workflow/transform/tests/fixture/nested-arrow-step-lexical-this-var-decl/output-step.js | Expected step-mode output hoisting as function for var-declarator case. |
| .changeset/swc-lexical-this-capture.md | Patch changeset describing the new lexical-this behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ction - core: Override `.bind` on step proxies so the bound function retains `stepId` and `__closureVarsFn`. Without this, a bound proxy that flows through workflow serialization (e.g. as a step argument) would be treated as a non-serializable plain function by `getStepFunctionReducer`. - swc-plugin: Detector now also walks `arrow.params` so `this` references in default values / destructuring initializers (e.g. `(x = this.foo) => ...`) trigger the `.bind(this)` path. - swc-plugin: Class bodies inside the arrow body are now treated as `this`-binding boundaries — `this` inside class field initializers, methods, etc. is bound to the class instance, not the outer arrow. The detector still walks `extends` clauses and computed property keys because those are evaluated in the surrounding scope. - spec.md: Sharpen the note about `this` in step bodies — it's syntactically allowed but only meaningful for instance-method steps and lexical-`this` arrow steps; other shapes compile but `this` will be whatever the caller of the step proxy passes. - Add `lexical-this-detector-edge-cases` fixture covering both the default-param positive case and the inner-class false-positive guard. - Strengthen the runtime test to assert `stepId` / `__closureVarsFn` survive `.bind(...)`.
… coverage Without this, a `useStep(...).bind(thisArg)` proxy that flows through workflow serialization (e.g. passed as a step argument) would lose its receiver: the reducer captured `stepId` but not the bound `this`, and the step-bundle reviver returned the raw registered step body which ignores any `this` the caller passes. Now: - step.ts `.bind` override stashes the bound value on the result as `__boundThis` so the reducer can see it. - The reducer serializes `boundThis` (using property presence so `bind(null)`/`bind(undefined)` round-trip faithfully). - The workflow-bundle reviver re-binds the freshly created proxy. - The step-bundle reviver wraps the registered body so it's invoked with `apply(boundThis, args)` (and still runs inside the closure-vars AsyncLocalStorage frame when `closureVars` is present). E2E coverage extends `instanceMethodStepWorkflow` with two new shapes: - `counter.makeAdder(7).add(2)` — direct invocation of a lexical-`this` arrow step. Verifies `bind(this)` carries `thisVal` to the queue and the step body sees `this.value` correctly. - `invokeAdderFromStep(adder.add, 3)` — passes the bound proxy as a step argument so the round-trip path exercises both the reducer and the step-bundle reviver, with the inner call running inline. Without the new `boundThis` plumbing this previously failed with `Cannot read properties of undefined (reading 'value')`.
karthikscale3
left a comment
There was a problem hiding this comment.
AI review: Solid implementation — the LexicalThisDetector, the .bind() override, and the serialization round-trip all hang together correctly. No regressions found. The arguments fix is a genuine bug fix and the dead this/arguments error-check removal is safe. All completed CI checks pass. LGTM
…+boundThis
- step.ts `.bind` override now also stashes `__boundArgs` when the caller
supplied prefilled args (`useStep(...).bind(thisArg, x, y)`). The
reducer serializes them as `boundArgs`, and both the workflow- and
step-bundle revivers re-apply them (the workflow reviver via
`bind(boundThis, ...boundArgs)`, the step-bundle reviver by
prepending to the runtime args). The SWC plugin only ever emits
`.bind(this)` today, but this keeps partial application faithful in
case hand-written code ever calls `.bind` with extra args.
- Add two new unit tests in `serialization.test.ts`:
- `closureVars + boundThis` combo: exercises the step-bundle reviver's
inner branch (`contextStorage.run(newContext, () =>
stepFn.apply(callThis, callArgs))`) in isolation — previously only
covered end-to-end by the `instanceMethodStepWorkflow` e2e test.
- `boundArgs` round-trip: codifies that prefilled args survive
serialization.
|
Backport PR opened against |
Summary
Teaches the SWC plugin to handle
"use step"arrow functions whose body lexically capturesthisfrom an enclosing method/function — a pattern that comes up naturally with the AI SDKtool({...})factory and other "method that returns an object literal containing arrow steps" shapes.Before this PR:
The compiler would hoist
executeto module scope as an arrow with a barereturn this.service…, so at runtimethiswasundefined. Workflow mode produced a step proxy with nothisplumbing.After this PR:
.bind(this)so invoking it captures the caller'sthisasthisValon the queue item.function(not an arrow) so the runtime's existingstepFn.apply(thisVal, args)rebindsthisinside the hoisted body.The runtime already had
thisValplumbing for instance-method steps; this PR is purely a compiler change feeding that existing pipeline.How
LexicalThisDetector(Visit) walks an arrow body looking forThisExpr. Recurses through nested arrows but stops atFunction/Constructor/MethodProp/GetterProp/SetterProp/ClassMethod/PrivateMethod/StaticBlockbecause those introduce their ownthis.nested_step_functionsgains areferences_lexical_this: boolfield, threaded through every push site.wrap_with_bind_this(expr)andcreate_step_proxy_reference_maybe_bound(...).was_arrow && !references_lexical_thisto decide arrow vs. function emission.Caveat
Capture only works at runtime if the captured
thisis serializable across the workflow→step boundary — i.e. the enclosing class implementsWORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE. Same precondition as the existing instance-method-step support. NestJS@Injectable()instances still need a separate provider rehydration path (the third option from the original issue).Output examples
Workflow mode:
Step mode (hoisted as
function, not arrow):Tests
nested-arrow-step-lexical-this,nested-arrow-step-lexical-this-var-decl, both modes). Total fixture tests: 114 → 118.step.test.tsruntime test exercisinguseStep(stepId).bind(instance)and assertingthisVal: instanceon the queue item.@workflow/coretests pass.pnpm typecheckclean;workbench/examplebuilds against the rebuilt wasm.Refs
#1865 (specifically the linked comment)