Skip to content

Commit 0b023f8

Browse files
committed
✨ Add inline() operation and build transforms for yield* delegation
When Effection operations compose via `yield*`, each level of nesting creates an intermediate JavaScript generator frame. The JS engine must unwind through every one of these frames on each call to `.next()`, `.return()`, or `.throw()`. For deeply nested or recursive operations, this means the cost of resuming a single yield point is O(depth), which is the primary driver of Effection being ~34x slower than vanilla addEventListener in benchmarks. This package provides both a runtime `inline()` function for manual optimization and build-time transforms (esbuild plugin + SWC WASM plugin) that apply the optimization automatically while preserving `yield*` syntax and type safety at the source level. The `inline()` function is a zero-overhead alternative to `yield*` that replaces nested generator delegation with an explicit iterator stack. Instead of: ```ts let value = yield* someOperation(); ``` You write: ```ts let value = yield inline(someOperation()); ``` When the first `inline()` effect enters, it replaces the coroutine's iterator with an `InlineIterator` that manages a stack of iterators in a local reduction loop. Subsequent nested `inline()` calls are caught within that same loop rather than creating additional generator frames. This means a chain of 100 nested inline() calls collapses to a single iterator stepping through a flat loop so that the reducer only ever sees one frame regardless of depth. Because this uses plain `yield` rather than `yield*`, there are no intermediate generator frames at all. The trade-off is that the return type is `unknown` (requiring a cast), and you lose the natural generator stack trace. This is acceptable for a manual optimization that users opt into on hot paths. The `InlineIterator` handles the full generator protocol: - `next()`: steps the current iterator, collapsing inline effects and popping the stack when sub-operations complete - `return()`: unwinds the entire stack, giving each iterator a chance to run its finally blocks (critical for structured concurrency cleanup) - `throw()`: propagates errors up the stack, giving each level a chance to catch (via `raise()` -> `propagate()`) To avoid the manual trade-offs, two build-time transforms are included that automatically rewrite `yield*` into `yield inline(...)` calls. Both produce identical output: they add an import for `$$inline` and convert every `yield* expr()` inside a generator function into `(yield $$inline(expr()))`. The esbuild plugin (`@effectionx/inline/esbuild`) hooks into `onLoad` for `.ts`, `.tsx`, `.js`, and `.jsx` files, running the SWC-based JS transform on each file's source. The SWC WASM plugin (`@effectionx/inline/swc`) is a Rust implementation compiled to `wasm32-wasip1` for use with `@swc/core` or any SWC-based toolchain. Both transforms support opting out: a `"no inline";` directive as the first statement skips the entire file, and a `/** @noinline */` JSDoc annotation on a generator function preserves its `yield*` expressions (while still transforming any nested generators).
1 parent ac893c5 commit 0b023f8

22 files changed

Lines changed: 4421 additions & 0 deletions

.github/workflows/preview.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ jobs:
1919
with:
2020
node-version: 22
2121

22+
- uses: dtolnay/rust-toolchain@stable
23+
with:
24+
targets: wasm32-wasip1
25+
2226
- run: pnpm install --frozen-lockfile
2327

2428
- name: Get changed packages

.github/workflows/publish.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ jobs:
5959
node-version: 24
6060
registry-url: 'https://registry.npmjs.org'
6161

62+
- uses: dtolnay/rust-toolchain@stable
63+
with:
64+
targets: wasm32-wasip1
65+
6266
- run: pnpm install --frozen-lockfile
6367

6468
- run: pnpm build

.github/workflows/test.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ jobs:
5454
node-version: 22
5555
cache: pnpm
5656

57+
- uses: dtolnay/rust-toolchain@stable
58+
with:
59+
targets: wasm32-wasip1
60+
5761
- run: pnpm install --frozen-lockfile
5862

5963
- name: Build
@@ -83,6 +87,10 @@ jobs:
8387
node-version: 22
8488
cache: pnpm
8589

90+
- uses: dtolnay/rust-toolchain@stable
91+
with:
92+
targets: wasm32-wasip1
93+
8694
- run: pnpm install --frozen-lockfile
8795

8896
- name: Build

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ dist/
4343
# Agent shell directories
4444
.agent-shell/
4545

46+
/.swc/

inline/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/swc/target/

inline/README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# 🚀 Inline
2+
3+
Collapse nested `yield*` delegation into a single flat iterator for
4+
performance-critical code paths.
5+
6+
---
7+
8+
On rare occasions, `yield*` syntax can cause a performance
9+
degradation, for example when there are many, many levels of
10+
recursion. The reason is because when javascript composes generators via `yield*`, each level
11+
of nesting creates an intermediate generator frame. The
12+
engine must unwind through every one of these frames on each call to
13+
`.next()`, `.return()`, or `.throw()`. For deeply nested or recursive
14+
operations, the cost of resuming a single yield point is O(depth).
15+
16+
Most of the time, this overhead is negligible and you should use plain
17+
`yield*` — it's type-safe, gives you clear stack traces, and composes
18+
naturally. But if you've profiled your code and identified deep
19+
`yield*` nesting as a bottleneck (e.g. recursive operations or tight
20+
inner loops with many layers of delegation), `inline()` lets you
21+
opt into a flat execution model where the cost is O(1) regardless of
22+
depth.
23+
24+
Instead of delegating with `yield*`:
25+
26+
```ts
27+
let value = yield* someOperation();
28+
```
29+
30+
Use `inline()` with a plain `yield`:
31+
32+
```ts
33+
import { inline } from "@effectionx/inline";
34+
35+
let value = (yield inline(someOperation())) as SomeType;
36+
```
37+
38+
The trade-off is that the return type is `unknown` (requiring a cast),
39+
and you lose the natural generator stack trace.
40+
41+
## Build-time Transform
42+
43+
Instead of manually converting each `yield*` call, you can apply the
44+
inline optimization automatically at build time. The transform rewrites
45+
every `yield*` expression inside generator functions into the equivalent
46+
`yield inline(...)` call. This means you can benefit from type-safety
47+
and helpful stack traces while developing, but ship optimal code to
48+
production.
49+
50+
### esbuild
51+
52+
```ts
53+
import { build } from "esbuild";
54+
import { inlinePlugin } from "@effectionx/inline/esbuild";
55+
56+
await build({
57+
entryPoints: ["src/index.ts"],
58+
bundle: true,
59+
plugins: [inlinePlugin()],
60+
});
61+
```
62+
63+
### SWC
64+
65+
A compiled WASM plugin is available for use with `@swc/core` or any
66+
SWC-based toolchain (e.g. Next.js, Parcel):
67+
68+
```ts
69+
import { transformSync } from "@swc/core";
70+
71+
let result = transformSync(source, {
72+
jsc: {
73+
experimental: {
74+
plugins: [["@effectionx/inline/swc", {}]],
75+
},
76+
},
77+
});
78+
```
79+
80+
Both transforms produce identical output: they add
81+
`import { inline as $$inline } from "@effectionx/inline"` and rewrite
82+
`yield* expr()` into `(yield $$inline(expr()))`.
83+
84+
You can opt out of the transform for specific functions with a
85+
`/** @noinline */` JSDoc annotation, or for an entire file by adding
86+
`"no inline";` as the first statement.

inline/esbuild.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it } from "@effectionx/bdd";
2+
import { type Operation, resource, until } from "effection";
3+
import { expect } from "expect";
4+
import { build } from "esbuild";
5+
import { inlinePlugin } from "./esbuild.ts";
6+
import fs from "node:fs";
7+
import path from "node:path";
8+
import os from "node:os";
9+
10+
describe("esbuild inlinePlugin", () => {
11+
it("transforms yield* in bundled output", function* () {
12+
let dir = yield* useTmpDir();
13+
let inputFile = path.join(dir, "input.ts");
14+
let outFile = path.join(dir, "output.js");
15+
16+
fs.writeFileSync(
17+
inputFile,
18+
`export function* gen() {
19+
let x = yield* foo();
20+
return x;
21+
}
22+
`,
23+
);
24+
25+
yield* until(
26+
build({
27+
entryPoints: [inputFile],
28+
outfile: outFile,
29+
bundle: false,
30+
format: "esm",
31+
plugins: [inlinePlugin()],
32+
write: true,
33+
}),
34+
);
35+
36+
let output = fs.readFileSync(outFile, "utf-8");
37+
38+
expect(output).toContain("$$inline");
39+
expect(output).toContain("@effectionx/inline");
40+
expect(output).not.toContain("yield*");
41+
});
42+
43+
it("does not transform files without yield*", function* () {
44+
let dir = yield* useTmpDir();
45+
let inputFile = path.join(dir, "input.ts");
46+
let outFile = path.join(dir, "output.js");
47+
48+
fs.writeFileSync(inputFile, `export const x = 1;\n`);
49+
50+
yield* until(
51+
build({
52+
entryPoints: [inputFile],
53+
outfile: outFile,
54+
bundle: false,
55+
format: "esm",
56+
plugins: [inlinePlugin()],
57+
write: true,
58+
}),
59+
);
60+
61+
let output = fs.readFileSync(outFile, "utf-8");
62+
63+
expect(output).not.toContain("$$inline");
64+
expect(output).not.toContain("@effectionx/inline");
65+
});
66+
});
67+
68+
function useTmpDir(): Operation<string> {
69+
return resource(function* (provide) {
70+
let dir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-inline-"));
71+
try {
72+
yield* provide(dir);
73+
} finally {
74+
fs.rmSync(dir, { recursive: true });
75+
}
76+
});
77+
}

inline/esbuild.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* esbuild plugin that applies the {@link @effectionx/inline} optimization
3+
* at build time. Transforms all `yield*` expressions inside generator
4+
* functions into `(yield inline(...))` calls.
5+
*
6+
* @example
7+
* ```ts
8+
* import { build } from "esbuild";
9+
* import { inlinePlugin } from "@effectionx/inline/esbuild";
10+
*
11+
* await build({
12+
* entryPoints: ["src/index.ts"],
13+
* bundle: true,
14+
* plugins: [inlinePlugin()],
15+
* });
16+
* ```
17+
*
18+
* @module
19+
*/
20+
21+
import type { Plugin } from "esbuild";
22+
import { readFile } from "node:fs/promises";
23+
import { transformSource } from "./transform.ts";
24+
25+
export function inlinePlugin(): Plugin {
26+
return {
27+
name: "effectionx-inline",
28+
setup(build) {
29+
build.onLoad({ filter: /\.[tj]sx?$/ }, async (args) => {
30+
let source = await readFile(args.path, "utf-8");
31+
let { code, transformed } = transformSource(source, args.path);
32+
33+
if (!transformed) {
34+
return undefined;
35+
}
36+
37+
let loader = args.path.endsWith(".tsx")
38+
? ("tsx" as const)
39+
: args.path.endsWith(".ts")
40+
? ("ts" as const)
41+
: args.path.endsWith(".jsx")
42+
? ("jsx" as const)
43+
: ("js" as const);
44+
45+
return { contents: code, loader };
46+
});
47+
},
48+
};
49+
}

inline/inline.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { type Operation, run, suspend, until } from "effection";
2+
import { describe, it } from "node:test";
3+
import { inline } from "./mod.ts";
4+
import { expect } from "expect";
5+
6+
describe("inline", () => {
7+
it("can be used for simple operations", async () => {
8+
let value = await run(function* () {
9+
return yield inline(until(Promise.resolve(10)));
10+
});
11+
expect(value).toEqual(10);
12+
});
13+
14+
it("can be used for operations with multiple yield points", async () => {
15+
let result = await run(function* () {
16+
let first = yield inline(
17+
(function* () {
18+
return yield inline(constant(5));
19+
})(),
20+
);
21+
let second = yield inline(
22+
(function* () {
23+
return yield inline(constant(5));
24+
})(),
25+
);
26+
return (first as number) + (second as number);
27+
});
28+
expect(result).toEqual(10);
29+
});
30+
31+
it("can be used for recursive operations", async () => {
32+
function* recurse(depth: number, total: number): Operation<number> {
33+
if (depth > 0) {
34+
return (yield inline(recurse(depth - 1, total + depth))) as number;
35+
} else {
36+
for (let i = 0; i < 10; i++) {
37+
total += yield* until(Promise.resolve(1));
38+
}
39+
return total;
40+
}
41+
}
42+
await expect(run(() => recurse(10, 0))).resolves.toEqual(65);
43+
});
44+
45+
it("successfully halts inlined iterators", async () => {
46+
let backout = 0;
47+
48+
function* recurse(depth: number, total: number): Operation<number> {
49+
if (depth > 0) {
50+
try {
51+
return (yield inline(recurse(depth - 1, total + depth))) as number;
52+
} finally {
53+
backout += (yield inline(until(Promise.resolve(1)))) as number;
54+
}
55+
} else {
56+
yield* suspend();
57+
return 10;
58+
}
59+
}
60+
61+
let task = run(() => recurse(10, 0));
62+
63+
await task.halt();
64+
65+
expect(backout).toEqual(10);
66+
});
67+
68+
it("handles unwinding when inlined iterators throw", async () => {
69+
interface CountingError extends Error {
70+
cause: number;
71+
}
72+
73+
function* recurse(depth: number): Operation<number> {
74+
if (depth > 0) {
75+
try {
76+
return (yield inline(recurse(depth - 1))) as number;
77+
} catch (err) {
78+
let counter = err as CountingError;
79+
let num = (yield inline(until(Promise.resolve(1)))) as number;
80+
counter.cause += num;
81+
throw counter;
82+
}
83+
} else {
84+
let error = Object.assign(new Error("bottom"), { cause: 0 });
85+
throw error;
86+
}
87+
}
88+
89+
try {
90+
await run(() => recurse(10));
91+
throw new Error(`expected to throw, but did not`);
92+
} catch (err) {
93+
expect((err as CountingError).cause).toEqual(10);
94+
}
95+
});
96+
});
97+
98+
function constant<T>(value: T): Operation<T> {
99+
return {
100+
[Symbol.iterator]: () => ({ next: () => ({ done: true, value }) }),
101+
};
102+
}

0 commit comments

Comments
 (0)