Skip to content

fix(swc-plugin): register class expressions via an IIFE instead of by name - #3971

Open
TooTallNate wants to merge 2 commits into
mainfrom
fix/swc-class-expression-registration-iife
Open

fix(swc-plugin): register class expressions via an IIFE instead of by name#3971
TooTallNate wants to merge 2 commits into
mainfrom
fix/swc-class-expression-registration-iife

Conversation

@TooTallNate

@TooTallNate TooTallNate commented Sep 4, 2026

Copy link
Copy Markdown
Member

Fixes #3929

Problem

Class expressions with "use step" methods or custom serialization were registered by module-level statements that reference the class by name. When the plugin could not resolve a module-scope binding it fell back to a placeholder AnonymousClass identifier, producing code like

registerStepFunction("step//@vercel/sandbox@3.1.0//AnonymousClass#readFile", AnonymousClass.prototype["readFile"]);

which is a guaranteed ReferenceError as soon as the module is evaluated. That is the failure in #3929.

While investigating, I found the same class of bug live on main in other shapes, even with the #1599 binding-name fix in place:

  • var A = class {…}, B = class {…} registered A's steps under B's name and B's under AnonymousClass
  • let X; X = class {…}, exports.X = class {…}, var X = (class {…}), { X: class {…} }, foo(class {…})AnonymousClass
  • a class declared inside a function emitted Inner.prototype[...] at module scope → ReferenceError

What actually happened in #3929

I could not reproduce the report with its declared versions. Every published @vercel/sandbox (2.8.0 → 3.2.1) ships var FileSystem = class { … }, every published @workflow/swc-plugin >= 4.1.0-beta.22 (everything any @workflow/next >= 4.0.2 pins) handles that shape, and a fresh Next 16.3.1 + Turbopack + @workflow/next@4.1.2 + @vercel/sandbox@3.1.0 app builds cleanly (static and dynamic import). The exact reported output is produced only by plugins <= 4.1.0-beta.21, which still load under @swc/core@1.15.3, so a stale plugin resolving in the reporter's monorepo is the most likely cause. Independently of that, the plugin should never have been able to emit this code, which is what this PR fixes.

Fix

Class expressions are wrapped in a single IIFE that receives the class, performs every registration recorded for it, and returns it:

var FileSystem = function(__wf_cls) {
    var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map()), __wf_fn;
    __wf_fn = __wf_cls.prototype["readFile"];
    __wf_reg.set("step//./input//FileSystem#readFile", __wf_fn);
    __wf_fn.stepId = "step//./input//FileSystem#readFile";
    Object.defineProperty(__wf_fn, "name", { value: "readFile", configurable: true });
    var __wf_cls_sym = Symbol.for("workflow-class-registry"), __wf_cls_reg = globalThis[__wf_cls_sym] || (globalThis[__wf_cls_sym] = new Map());
    __wf_cls_reg.set("class//./input//FileSystem", __wf_cls);
    Object.defineProperty(__wf_cls, "classId", { value: "class//./input//FileSystem", writable: false, enumerable: false, configurable: false });
    return __wf_cls;
}(class FileSystem {  });

The registration closes over the class value, so it no longer depends on any name being in scope, and every position a class expression can appear in produces the same shape. Registry lookups are hoisted once per registry inside the IIFE rather than repeated per registration.

Names are still needed for IDs (step//…//Foo#run). They are derived, in order, from:

  1. the assigned variable (var Foo = class _Foo {}Foo; covers X = class {}, parenthesized initializers, var X = exports.X = class {}, and per-declarator for multi-declarator statements),
  2. the class's own identifier (foo(class Plugin {})),
  3. the property key it is assigned to (exports.Foo = class {}, { Foo: class {} }),
  4. otherwise a generated AnonymousClass<N> (foo(class {…}), array element, conditional branch). N counts only anonymous classes that have something to register, and is de-duplicated against declared identifiers. Like _anonymousStep<N>, these are positional; the spec calls that out.

.name is preserved by inserting the identifier for binding-derived names (as the existing serde path already did), via Object.defineProperty(__wf_cls, "name", …) for key-derived names (which must not be introduced as a binding), and left as-is for generated names.

Classes declared inside a function are a compile error when they use steps/workflows/serde: their registration would only run when (and each time) that function runs, not at module load, so their steps could not be resolved by ID. One error per class, none in detect mode. Nested classes without steps or serialization are unaffected.

Dead-code elimination keeps declarations whose initializer contains a wrapped class. The plugin's DCE removes any unreferenced module-level var/const; registered class expressions used to survive it only because the module-end registration referenced their binding. Evaluating the initializer is now what registers the class, so such declarations are kept explicitly (const registry = new Map([["point", class { …serde… }]])).

Class declarations keep their existing module-level output. The two inline-registration IIFE builders and the module-end emission loops were factored into shared statement builders parameterized by the class reference; the 123 fixtures not involving class expressions are byte-identical.

Testing

  • cargo test: 133 fixtures (new class-expression-binding-shapes and class-expression-anonymous-generated-names covering every shape above, including an unreferenced binding surviving DCE) + 24 error cases (new nested-class-step-methods)
  • Built the wasm and ran it over the published @vercel/sandbox@3.1.0 dist/*.js in both modes, then evaluated the transformed modules in Node: 33 steps and 5 classes registered, FileSystem.name === "FileSystem", workflow-mode proxies attached
  • @workflow/builders (502) and @workflow/next (71) vitest suites
  • workbench/nextjs-turbopack production build; e2e serde subset (-t "Serde|serde|serializ", 10 tests) against the dev server
  • spec.md updated; changeset included

…nameable classes

Class expressions with "use step" methods or custom serialization were
registered by module-level statements referencing the class by name. When no
module-scope binding could be resolved the plugin fell back to a placeholder
`AnonymousClass` identifier, which is a guaranteed ReferenceError at module
evaluation (#3929). Other shapes were silently wrong as well:
`var A = class {}, B = class {}` registered A's steps under B, `X = class {}`
assignments and classes nested inside functions emitted unresolvable
references.

Class expressions are now wrapped in a single IIFE that receives the class,
performs every registration recorded for it, and returns it, so the
registration no longer depends on a name being in scope. The class name is
still needed for step/class IDs and is derived from the assigned variable,
the class's own identifier, or the property key it is assigned to
(`exports.Foo = class {}`, `{ Foo: class {} }`). When none is available, or
the class is declared inside a function, the plugin emits a compile error
instead of broken code.

Class declarations keep their existing module-level output; the emitters
were factored so both paths share the same statement builders.
@TooTallNate
TooTallNate requested a review from a team as a code owner September 4, 2026 18:17
Copilot AI lite review requested due to automatic review settings September 4, 2026 18:17
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8f107f9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@workflow/swc-plugin Patch
@workflow/astro Patch
@workflow/builders Patch
@workflow/cli Patch
@workflow/nest Patch
@workflow/next Patch
@workflow/nitro Patch
@workflow/rollup Patch
@workflow/sveltekit Patch
workflow Patch
@workflow/vite Patch
@workflow/vitest Patch
@workflow/world-testing Patch
@workflow/nuxt Patch
@workflow/core Patch
@workflow/web-shared Patch
@workflow/web Patch

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

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
example-nextjs-workflow-turbopack Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
example-nextjs-workflow-webpack Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
example-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-astro-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-express-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-fastify-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-hono-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-nestjs-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-nitro-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-nuxt-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-python-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-sveltekit-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-tanstack-start-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workbench-vite-workflow Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workflow-docs Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workflow-swc-playground Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workflow-tarballs Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC
workflow-web Ready Ready Preview, v0 Sep 4, 2026 6:45pm UTC

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

⚠️ Flaky E2E Tests (passed on retry)

These tests failed at least once and passed on a retry. A recurring entry here is a real race worth investigating.

  • cancelRun via CLI - cancelling a running workflow (sveltekit)
  • cancelRun via CLI - cancelling a running workflow (vite)
  • sleepWinsRaceWorkflow (vite)
  • startFromWorkflow - calling start() directly inside a workflow function with hook communication (nextjs-turbopack)
  • stepFunctionWithClosureWorkflow - step function with closure variables passed as argument (nextjs-webpack)

🛠 Infra Events (absorbed by the harness)

Platform anomalies the e2e harness detected and worked around (e.g. a run the queue never picked up, replaced by a fresh run). Clustered timestamps indicate a backend blip; a steady drip indicates a platform issue worth escalating.

  • cold-start-warmup · suite warmup (tanstack-start) · at 18:47:38Z · abandoned wrun_01M1PVY6H8YYSNYMR44098CBFV
  • run-pickup-stall · hookCleanupTestWorkflow - hook token reuse after workflow completion (nextjs-webpack) · at 18:56:27Z · abandoned wrun_01M1PWEJQ1HN8KCXP2MK9ZDH01

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 3636 0 684 4320
✅ 💻 Local Development 3922 0 558 4480
✅ 📦 Local Production 3922 0 558 4480
✅ 🐘 Local Postgres 3922 0 558 4480
✅ 🪟 Windows 320 0 0 320
✅ 🌐 Cross-language Conformance 68 0 73 141
✅ vercel-http-transport 817 0 143 960
✅ vercel-multi-region 27 0 0 27
✅ vercel-ws-transport 553 0 87 640
Total 17187 0 2661 19848
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro-node 132 0 28
✅ astro-quickjs 132 0 28
✅ example-node 132 0 28
✅ example-quickjs 132 0 28
✅ express-node 132 0 28
✅ express-quickjs 132 0 28
✅ fastify-node 132 0 28
✅ fastify-quickjs 132 0 28
✅ hono-node 132 0 28
✅ hono-quickjs 132 0 28
✅ nest-node 132 0 28
✅ nest-quickjs 132 0 28
✅ nextjs-turbopack-node 157 0 3
✅ nextjs-turbopack-quickjs 157 0 3
✅ nextjs-webpack-node 157 0 3
✅ nextjs-webpack-quickjs 157 0 3
✅ nitro-node 132 0 28
✅ nitro-quickjs 132 0 28
✅ nuxt-node 132 0 28
✅ nuxt-quickjs 132 0 28
✅ python-node 66 0 94
✅ sveltekit-node 151 0 9
✅ sveltekit-quickjs 151 0 9
✅ tanstack-start-node 132 0 28
✅ tanstack-start-quickjs 132 0 28
✅ vite-node 132 0 28
✅ vite-quickjs 132 0 28

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable-node 134 0 26
✅ astro-stable-quickjs 134 0 26
✅ express-stable-node 134 0 26
✅ express-stable-quickjs 134 0 26
✅ fastify-stable-node 134 0 26
✅ fastify-stable-quickjs 134 0 26
✅ hono-stable-node 134 0 26
✅ hono-stable-quickjs 134 0 26
✅ nest-stable-node 134 0 26
✅ nest-stable-quickjs 134 0 26
✅ nextjs-turbopack-canary-node 141 0 19
✅ nextjs-turbopack-canary-quickjs 141 0 19
✅ nextjs-turbopack-stable-node 160 0 0
✅ nextjs-turbopack-stable-quickjs 160 0 0
✅ nextjs-webpack-canary-node 141 0 19
✅ nextjs-webpack-canary-quickjs 141 0 19
✅ nextjs-webpack-stable-node 160 0 0
✅ nextjs-webpack-stable-quickjs 160 0 0
✅ nitro-stable-node 134 0 26
✅ nitro-stable-quickjs 134 0 26
✅ nuxt-stable-node 134 0 26
✅ nuxt-stable-quickjs 134 0 26
✅ sveltekit-stable-node 153 0 7
✅ sveltekit-stable-quickjs 153 0 7
✅ tanstack-start-node 134 0 26
✅ tanstack-start-quickjs 134 0 26
✅ vite-stable-node 134 0 26
✅ vite-stable-quickjs 134 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack-node 160 0 0
✅ nextjs-turbopack-quickjs 160 0 0

✅ 🌐 Cross-language Conformance

App Passed Failed Skipped
✅ python 68 0 73

✅ vercel-http-transport

App Passed Failed Skipped
✅ example 132 0 28
✅ express 132 0 28
✅ hono 132 0 28
✅ nextjs-turbopack 157 0 3
✅ nitro 132 0 28
✅ vite 132 0 28

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

✅ vercel-ws-transport

App Passed Failed Skipped
✅ example 132 0 28
✅ express 132 0 28
✅ nextjs-turbopack 157 0 3
✅ vite 132 0 28

📋 View full workflow run

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 8f107f9 · Fri, 04 Sep 2026 18:59:14 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 268 (-63%) 💚 1260 🔴 (+15%) 🔻 1310 🔴 (+18%) 🔻 1375 🔴 (+14%) 30
TTFS stream 1185 (+439%) 🔻 1252 🔴 (+19%) 🔻 1267 🔴 (+15%) 🔻 1420 🔴 (±0%) 30
TTFS hook + stream 1430 (+63%) 🔻 1528 🔴 (+15%) 🔻 1558 🔴 (+11%) 1626 🔴 (+4.2%) 30
Fan-out TTFS Promise.all(100 steps) 546 (-37%) 💚 1742 (+1.8%) 1802 (+1.1%) 1940 (+3.6%) 10
Fan-out TTLS Promise.all(100 steps) 1978 (-14%) 4819 (+33%) 🔻 4954 (+31%) 🔻 8478 (+17%) 🔻 10
STSO 1020 steps (inline) 92 (-8.0%) 129 (-0.8%) 147 (-4.5%) 201 (-1.0%) 1019
WO 1020 steps 130745 (-0.8%) 130745 (-0.8%) 130745 (-0.8%) 130745 (-0.8%) 1
CRTT first chunk (pooled) 53 (-26%) 💚 108 (-24%) 💚 130 (-39%) 💚 376 (+5.9%) 28

Streams

Scenario CRTT 1st p75 p90 p99 CDV max iters
paced control (100/s, 60B) 78 (-21%) 126 (-34%) 167 (-45%) 1265 (+157%) 117 (-48%) 10
size sweep (100/s, 160B-12KB) 67.5 (-35%) 116 (-49%) 172 (-51%) 442 (-30%) 112 (-47%) 10
replay gateway-gpt-5.4-nano-2000t (1x) 122 (-11%) 129 (-32%) 176 (-45%) 420 (-27%) 389 (-11%) 3
replay eve-gpt-5.6-sol-2000t (1x) 74 (-29%) 106 (-46%) 136 (-56%) 494 (-10%) 488 (+7%) 2
replay eve-gpt-5.6-sol-2000t (2x) 127 (-11%) 152 (-54%) 221 (-55%) 452 (-52%) 233 (-50%) 3
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 131526ms → this run 130396ms (Δ -1130ms, -1%)

 50-100 ms  ┃                         main   0  this   1    +1
100-150 ms  ███████████████████████┃  main 899  this 926   +27
150-200 ms  █┃█                       main 108  this  81   -27
200-250 ms  ┃                         main   8  this   7    -1
250-300 ms  ┃                         main   2  this   2    +0
350-400 ms  ┃                         main   1  this   0    -1
400-450 ms  ┃                         main   1  this   2    +1
📈 CRTT drill-down vs main (RTT distributions & profiles)
variant  RTT 1ms→5s+             avg         p50         p90           p99     n
control  ······██▁▁▁··   151.8 (-4%)  102 (-26%)  167 (-45%)  1265 (+157%)  3000
sweep    ·····▁█▅▁····   99.9 (-43%)   92 (-41%)  172 (-51%)    442 (-30%)  3000
gw 1x    ·····▁█▇▁····  107.8 (-32%)   99 (-27%)  176 (-45%)    420 (-27%)  5295
eve 1x   ·····▁█▄▁▁···   96.8 (-41%)   85 (-37%)  136 (-56%)    494 (-10%)  5186
eve 2x   ·····▁██▂▁···    119 (-48%)  104 (-52%)  221 (-55%)    452 (-52%)  7779

RTT over stream progress (avg per tenth of stream, bars scaled min→max):

control  ▇█▇▆▅▂▂▁▃▄  98–206ms
sweep    █▄▅▄▂▁▂▂▂▁  88–127ms
gw 1x    ▇█▄▅▇▃▆▃▁▆  96–117ms
eve 1x   ▄▅▅▆▁█▆▆▄▅  77–114ms
eve 2x   ▇▂▅▅▁▅▄█▄▁  98–145ms

RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max):

sweep  ▄█▇▆▃▂▁  96–104ms

Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max):

control  ██▃▄▅▁▆▂▆▁  27–43ms
sweep    ▆▆▇█▆▄▆▅▇▁  30–40ms
gw 1x    ▄██▁█▅▇▅▃▅  27–34ms
eve 1x   ▃▇▁▅▁█▆▄▄█  18–24ms
eve 2x   █▅▁▄▁▆▄▄▄▄  17–22ms
ℹ️ Metric definitions & methodology

Streams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach.

The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). = main, = this run, = fill.

The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, · = empty) and mean RTT/positive-CDV profile lines over stream progress and chunk size. Histograms, avgs, and profiles merge exactly across runs; p50–p99 are percentile-of-percentiles. Per-index rows live in the artifacts.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles

Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t eaf22f5946e7c61f3c65c7006d550df180cfabd4e706254a09f22aec0cfb420d · gateway-gpt-5.4-nano-2000t 6f24ac518b6b83ff1d0e85a5fe78230db192716d66a7fc6b2fe022752001d041

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600

All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = start() → first step body (includes dispatch + any cold start); Fan-out TTFS/TTLS = first/last step completion of one Promise.all from the same anchor (the gap is the runtime’s fan-out spread); STSO/WO between step bodies; CRTT inside the workflow (excludes the api.vercel.com read path).

Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It substantially refactors SWC transform code generation in a high-impact area (class/step registration semantics), so it warrants final maintainer review despite strong fixture/spec coverage.

Pull request overview

This PR fixes a SWC transform correctness issue where class expressions containing "use step" methods or custom serialization could produce module-level registrations that reference a non-existent identifier (e.g. AnonymousClass.prototype[...]), causing guaranteed ReferenceError during module evaluation (per #3929). It does so by changing class-expression registration to run inside an IIFE that closes over the class value, and by emitting compile-time errors for classes that cannot be safely referenced/registered from module scope.

Changes:

  • Wrap class expressions that require registration (steps/workflows/getters/serde) in a single registration IIFE that receives the class, performs all recorded registrations, and returns it.
  • Add explicit compile errors for (a) unnameable anonymous class expressions using step/workflow/serde features and (b) classes using those features declared inside functions.
  • Expand/refresh fixtures + update the plugin spec to document the new class-expression handling and new diagnostics.
File summaries
File Description
packages/swc-plugin-workflow/transform/src/lib.rs Implements class-expression registration via IIFE, name-resolution for IDs, and new compile-time errors for unreferenceable classes; refactors registration builders to share logic.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-shapes/input.js Adds a comprehensive fixture covering multiple bundler-emitted class-expression shapes that must remain referenceable and correctly named.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-shapes/output-step.js Updates expected step-mode output to validate IIFE-based registration, hoisted registry lookups, and stable IDs/names.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-shapes/output-workflow.js Updates expected workflow-mode output to validate proxy reattachment + class registration inside the IIFE.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-step.js Updates expected output to reflect class-expression serde registration happening inline via IIFE rather than module-end emissions.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-workflow.js Same as above for workflow mode.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name-step-methods/output-step.js Updates expected output so step registrations for class expressions occur within the class-expression IIFE.
packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name-step-methods/output-workflow.js Same as above for workflow mode.
packages/swc-plugin-workflow/transform/tests/errors/anonymous-class-step-methods/input.js Adds error fixtures for anonymous/unnameable class expressions using step/workflow/serde features.
packages/swc-plugin-workflow/transform/tests/errors/anonymous-class-step-methods/output-step.js Expected transformed JS output for the error fixture (showing unaffected valid cases).
packages/swc-plugin-workflow/transform/tests/errors/anonymous-class-step-methods/output-step.stderr Expected diagnostics for anonymous/unnameable class expressions in step mode.
packages/swc-plugin-workflow/transform/tests/errors/anonymous-class-step-methods/output-workflow.js Expected transformed JS output for the error fixture in workflow mode.
packages/swc-plugin-workflow/transform/tests/errors/anonymous-class-step-methods/output-workflow.stderr Expected diagnostics for anonymous/unnameable class expressions in workflow mode.
packages/swc-plugin-workflow/transform/tests/errors/nested-class-step-methods/input.js Adds error fixtures for nested (in-function) classes using step/workflow/serde features.
packages/swc-plugin-workflow/transform/tests/errors/nested-class-step-methods/output-step.js Expected transformed JS output for the nested-class error fixture (showing unaffected valid cases).
packages/swc-plugin-workflow/transform/tests/errors/nested-class-step-methods/output-step.stderr Expected diagnostics for nested classes in step mode.
packages/swc-plugin-workflow/transform/tests/errors/nested-class-step-methods/output-workflow.js Expected transformed JS output for the nested-class error fixture in workflow mode.
packages/swc-plugin-workflow/transform/tests/errors/nested-class-step-methods/output-workflow.stderr Expected diagnostics for nested classes in workflow mode.
packages/swc-plugin-workflow/spec.md Updates the spec to explain the new IIFE-based class-expression strategy, name derivation order, and the new error cases.
.changeset/class-expression-registration-iife.md Adds a patch changeset describing the behavior change and new diagnostics.
Review details
  • Files reviewed: 19/20 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Sim World

Simulated world deterministic testing for races. Traces

🟠 world-sim scenario book — 1 fail of 41 total

fence=per-spec

scenario outcome events virt replay violations
smoke-no-steps completed 3 0ms ok 0
smoke-one-step completed 6 0ms ok 0
hook-at-step-started completed 12 0ms ok 0
hook-at-step-completed completed 12 0ms ok 0
hook-at-hook-created completed 12 0ms ok 0
deadline-hook-wins completed 7 1.0h ok 0
deadline-expires completed 7 1.0h ok 0
long-sleep completed 11 30.0d ok 0
hook-never-arrives stalled 3 0ms skipped 0
step-retries-twice completed 10 2.0s ok 0
parallel-steps completed 9 0ms ok 0
hook-on-execution-state completed 12 0ms ok 0
peek-hook-before-branch completed 12 0ms ok 0
peek-hook-after-branch completed 12 0ms ok 0
peek-hook-at-registration completed 12 0ms ok 0
race-hook-before-probe completed 12 0ms ok 0
race-hook-after-probe completed 12 0ms ok 0
race-duplicate-delivery completed 13 0ms ok 0
attr-hook-before-step completed 11 0ms ok 0
attr-hook-after-step completed 11 0ms ok 0
attr-from-step-body completed 13 0ms ok 0
fork-hook-after-timeout completed 14 1.0m ok 0
fork-hook-before-timeout completed 14 1.0m ok 0
count-hook-after-timeout completed 17 1.0m ok 0
count-hook-before-timeout completed 20 1.0m ok 0
stale-read-step-count-fork completed 20 1.0m ok 0
stale-read-equal-step-counts completed 14 1.0m ok 0
step-vs-step-fork completed 12 0ms ok 0
step-vs-step-fork-fenced completed 12 0ms ok 0
fence-catches-benign-direction completed 12 5ms ok 0
in-flight-before-decision completed 17 1.0m ok 0
in-flight-before-decision-counted completed 17 1.0m ok 0
in-flight-after-decision completed 19 2.0m ok 0
stale-read-step-count-fork-fenced completed 20 1.0m ok 0
fork-hook-wins completed 13 1.0m ok 0
fork-timeout-wins completed 13 1.0m ok 0
unclaimed-payload-under-fork completed 17 1.0m ok 0
claimed-payload-under-fork completed 17 1.0m ok 0
writers-independent-step-bodies completed 12 0ms ok 0
writers-scripted-tempo completed 12 0ms ok 0
cancel-mid-step cancelled 7 0ms skipped 0

Full trace: world-sim.txt

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor
Framework Flow route Step reg. Framework output
hono 201.6 KiB (±0) 40.8 KiB (±0) 1.77 MiB (±0)
nextjs-turbopack 207.0 KiB (±0) 439 B (±0) 768.7 KiB (±0)
About these numbers

Sizes are gzip; parentheses show the change against main.
Flow route and Step reg. gate this job, on raw bytes rather than the gzip shown, at max(2%, 50.0 KiB). Framework output is informational.

8f107f9 · run

…ad of erroring

With registration happening inside the IIFE, an anonymous class expression
in a position that provides no name (`foo(class { ... })`, an array element,
a conditional branch) only needs a name for its step/class IDs. Generate a
deterministic `AnonymousClass<N>`, counting only anonymous classes that have
something to register, instead of rejecting them. Classes declared inside a
function remain an error.

Dead-code elimination now keeps module-level declarations whose initializer
contains a wrapped class expression: evaluating the initializer is what
registers the class, and the binding may be otherwise unreferenced.
@TooTallNate TooTallNate changed the title fix(swc-plugin): register class expressions via an IIFE and reject unnameable classes fix(swc-plugin): register class expressions via an IIFE instead of by name Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@vercel/sandbox breaks the Next build: SWC plugin emits AnonymousClass.prototype[...] for the SDK's anonymous class expressions

2 participants