Skip to content

Commit 3fb48a7

Browse files
committed
✨ Add inline() operation to collapse nested 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. 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 `while(true)` 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 -- 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. A future SWC/Babel plugin could apply this transformation automatically at build time while preserving `yield*` syntax and type safety at the source level. 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()`)
1 parent ac893c5 commit 3fb48a7

8 files changed

Lines changed: 329 additions & 0 deletions

File tree

inline/README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# ⭐ optimize
2+
3+
4+
5+
On rare occasions, `yield*`` syntax can cause a performance degradation, for example when there are many, many levels of recursion.
6+
7+
The idea is that we can roll up any number of levels of recursion into a single yield point by transforming:
8+
```js
9+
yield* operation;
10+
```
11+
into:
12+
13+
```js
14+
yield star(operation);
15+
```
16+

inline/mod.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Ok } from "effection";
2+
import type { Effect, Operation, Result } from "effection";
3+
4+
export function inline<T>(operation: Operation<T>): Inline<T> {
5+
return {
6+
operation,
7+
description: "inline()",
8+
enter(resolve, routine) {
9+
let current = routine.data.iterator;
10+
if (isInlineIterator(current)) {
11+
current.stack.push(current.current);
12+
current.current = operation[Symbol.iterator]();
13+
} else {
14+
let inlined = new InlineIterator(operation, current);
15+
Object.defineProperty(routine.data, "iterator", {
16+
get: () => inlined,
17+
configurable: true,
18+
});
19+
}
20+
resolve(Ok() as Result<T>);
21+
return (didExit) => didExit(Ok());
22+
},
23+
};
24+
}
25+
26+
interface Inline<T> extends Effect<T> {
27+
operation: Operation<T>;
28+
}
29+
30+
function isInline<T>(effect: Effect<T>): effect is Inline<T> {
31+
return "operation" in effect;
32+
}
33+
34+
type EffectIterator = Iterator<Effect<unknown>, unknown, unknown>;
35+
36+
function isInlineIterator(
37+
iterator: EffectIterator,
38+
): iterator is InlineIterator {
39+
return "@effectionx/inline" in iterator;
40+
}
41+
42+
class InlineIterator implements EffectIterator {
43+
"@effectionx/inline" = true;
44+
stack: EffectIterator[];
45+
current: EffectIterator;
46+
47+
constructor(operation: Operation<unknown>, original: EffectIterator) {
48+
this.stack = [original];
49+
this.current = operation[Symbol.iterator]();
50+
}
51+
52+
next(value: unknown): IteratorResult<Effect<unknown>, unknown> {
53+
let next: IteratorResult<Effect<unknown>, unknown>;
54+
try {
55+
next = this.current.next(value);
56+
} catch (error) {
57+
return this.raise(error);
58+
}
59+
return this.step(next);
60+
}
61+
62+
return(value: unknown): IteratorResult<Effect<unknown>, unknown> {
63+
if (this.current.return) {
64+
let result: IteratorResult<Effect<unknown>, unknown>;
65+
try {
66+
result = this.current.return(value);
67+
} catch (error) {
68+
return this.raise(error);
69+
}
70+
if (!result.done) {
71+
return result;
72+
}
73+
}
74+
75+
while (this.stack.length > 0) {
76+
let top = this.stack.pop()!;
77+
this.current = top;
78+
if (top.return) {
79+
let result: IteratorResult<Effect<unknown>, unknown>;
80+
try {
81+
result = top.return(value);
82+
} catch (error) {
83+
return this.raise(error);
84+
}
85+
if (!result.done) {
86+
return result;
87+
}
88+
}
89+
}
90+
91+
return { done: true, value };
92+
}
93+
94+
throw(error: unknown): IteratorResult<Effect<unknown>, unknown> {
95+
return this.raise(error);
96+
}
97+
98+
step(
99+
next: IteratorResult<Effect<unknown>, unknown>,
100+
): IteratorResult<Effect<unknown>, unknown> {
101+
while (true) {
102+
if (next.done) {
103+
let top = this.stack.pop();
104+
if (!top) {
105+
return next;
106+
}
107+
this.current = top;
108+
try {
109+
next = this.current.next(next.value);
110+
} catch (error) {
111+
return this.raise(error);
112+
}
113+
} else {
114+
let effect = next.value;
115+
if (isInline(effect)) {
116+
this.stack.push(this.current);
117+
this.current = effect.operation[Symbol.iterator]();
118+
try {
119+
next = this.current.next(undefined);
120+
} catch (error) {
121+
return this.raise(error);
122+
}
123+
} else {
124+
return next;
125+
}
126+
}
127+
}
128+
}
129+
130+
raise(error: unknown): IteratorResult<Effect<unknown>, unknown> {
131+
if (this.current.throw) {
132+
let next: IteratorResult<Effect<unknown>, unknown>;
133+
try {
134+
next = this.current.throw(error);
135+
} catch (rethrown) {
136+
return this.propagate(rethrown);
137+
}
138+
return this.step(next);
139+
}
140+
141+
return this.propagate(error);
142+
}
143+
144+
propagate(error: unknown): IteratorResult<Effect<unknown>, unknown> {
145+
let top = this.stack.pop();
146+
if (!top) {
147+
throw error;
148+
}
149+
this.current = top;
150+
return this.raise(error);
151+
}
152+
}

inline/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@effectionx/inline",
3+
"description": "Optimize deeply nested operations by collapsing yield delegation into a single iterator",
4+
"version": "0.0.1",
5+
"type": "module",
6+
"main": "./dist/mod.js",
7+
"types": "./dist/mod.d.ts",
8+
"exports": {
9+
".": {
10+
"development": "./mod.ts",
11+
"default": "./dist/mod.js"
12+
}
13+
},
14+
"peerDependencies": {
15+
"effection": "^3 || ^4"
16+
},
17+
"license": "MIT",
18+
"author": "engineering@frontside.com",
19+
"repository": {
20+
"type": "git",
21+
"url": "git+https://github.com/thefrontside/effectionx.git"
22+
},
23+
"bugs": {
24+
"url": "https://github.com/thefrontside/effectionx/issues"
25+
},
26+
"engines": {
27+
"node": ">= 22"
28+
},
29+
"sideEffects": false,
30+
"devDependencies": {
31+
"@effectionx/bdd": "workspace:*"
32+
}
33+
}

inline/star.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+
}

inline/tsconfig.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"extends": "../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "dist",
5+
"rootDir": "."
6+
},
7+
"include": ["**/*.ts"],
8+
"exclude": ["**/*.test.ts", "dist"],
9+
"references": [
10+
{
11+
"path": "../bdd"
12+
}
13+
]
14+
}

pnpm-lock.yaml

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ packages:
1414
- "raf"
1515
- "scope-eval"
1616
- "signals"
17+
- "inline"
1718
- "stream-helpers"
1819
- "stream-yaml"
1920
- "task-buffer"

tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
{ "path": "raf" },
2727
{ "path": "scope-eval" },
2828
{ "path": "signals" },
29+
{ "path": "inline" },
2930
{ "path": "stream-helpers" },
3031
{ "path": "stream-yaml" },
3132
{ "path": "task-buffer" },

0 commit comments

Comments
 (0)