Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/preview.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ jobs:
with:
node-version: 22

- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1

- run: pnpm install --frozen-lockfile

- name: Get changed packages
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ jobs:
node-version: 24
registry-url: 'https://registry.npmjs.org'

- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1

- run: pnpm install --frozen-lockfile

- run: pnpm build
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ jobs:
node-version: 22
cache: pnpm

- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1

- run: pnpm install --frozen-lockfile

- name: Build
Expand Down Expand Up @@ -86,6 +90,10 @@ jobs:
node-version: 22
cache: pnpm

- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1

- run: pnpm install --frozen-lockfile

- name: Build
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,6 @@ dist/
# Plans directory (working documents)
.opencode/plans/

# Rust compilation artifacts for SWC build
/.swc/

1 change: 1 addition & 0 deletions inline/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/swc/target/
89 changes: 89 additions & 0 deletions inline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# 🚀 Inline

Collapse nested `yield*` delegation into a single flat iterator for
performance-critical code paths.

---

On rare occasions, `yield*` syntax can cause a performance
degradation, for example when there are many, many levels of
recursion. The reason is because when javascript composes generators via `yield*`, each level
of nesting creates an intermediate generator frame. The
engine must unwind through every one of these frames on each call to
Comment thread
cowboyd marked this conversation as resolved.
`.next()`, `.return()`, or `.throw()`. For deeply nested or recursive
operations, the cost of resuming a single yield point is O(depth).

Most of the time, this overhead is negligible and you should use plain
`yield*` — it's type-safe, gives you clear stack traces, and composes
naturally. But if you've profiled your code and identified deep
`yield*` nesting as a bottleneck (e.g. recursive operations or tight
inner loops with many layers of delegation), `inline()` lets you
opt into a flat execution model where the cost is O(1) regardless of
depth.

Instead of delegating with `yield*`:

```ts
let value = yield* someOperation();
```

Use `inline()` with a plain `yield`:

```ts
import { inline } from "@effectionx/inline";

let value = (yield inline(someOperation())) as SomeType;
```

The trade-off is that the return type is `unknown` (requiring a cast),
and you lose the natural generator stack trace.

> **Note:** Source map support for the build-time transforms is not yet
> available but is planned for a future release.

## Build-time Transform

Instead of manually converting each `yield*` call, you can apply the
inline optimization automatically at build time. The transform rewrites
every `yield*` expression inside generator functions into the equivalent
`yield inline(...)` call. This means you can benefit from type-safety
and helpful stack traces while developing, but ship optimal code to
production.

### esbuild

```ts
import { build } from "esbuild";
import { inlinePlugin } from "@effectionx/inline/esbuild";

await build({
entryPoints: ["src/index.ts"],
bundle: true,
plugins: [inlinePlugin()],
});
```

### SWC

A compiled WASM plugin is available for use with `@swc/core` or any
SWC-based toolchain (e.g. Next.js, Parcel):

```ts
import { transformSync } from "@swc/core";

let result = transformSync(source, {
jsc: {
experimental: {
plugins: [["@effectionx/inline/swc", {}]],
},
},
});
```

Both transforms produce identical output: they add
`import { inline as $$inline } from "@effectionx/inline"` and rewrite
`yield* expr()` into `(yield $$inline(expr()))`.

You can opt out of the transform for specific functions with a
`/** @noinline */` JSDoc annotation, or for an entire file by adding
`"no inline";` as the first statement.
77 changes: 77 additions & 0 deletions inline/esbuild.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it } from "@effectionx/bdd";
import { type Operation, resource, until } from "effection";
import { expect } from "expect";
import { build } from "esbuild";
import { inlinePlugin } from "./esbuild.ts";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";

describe("esbuild inlinePlugin", () => {
it("transforms yield* in bundled output", function* () {
let dir = yield* useTmpDir();
let inputFile = path.join(dir, "input.ts");
let outFile = path.join(dir, "output.js");

fs.writeFileSync(
inputFile,
`export function* gen() {
let x = yield* foo();
return x;
}
`,
);

yield* until(
build({
entryPoints: [inputFile],
outfile: outFile,
bundle: false,
format: "esm",
plugins: [inlinePlugin()],
write: true,
}),
);

let output = fs.readFileSync(outFile, "utf-8");

expect(output).toContain("$$inline");
expect(output).toContain("@effectionx/inline");
expect(output).not.toContain("yield*");
});

it("does not transform files without yield*", function* () {
let dir = yield* useTmpDir();
let inputFile = path.join(dir, "input.ts");
let outFile = path.join(dir, "output.js");

fs.writeFileSync(inputFile, `export const x = 1;\n`);

yield* until(
build({
entryPoints: [inputFile],
outfile: outFile,
bundle: false,
format: "esm",
plugins: [inlinePlugin()],
write: true,
}),
);

let output = fs.readFileSync(outFile, "utf-8");

expect(output).not.toContain("$$inline");
expect(output).not.toContain("@effectionx/inline");
});
});

function useTmpDir(): Operation<string> {
return resource(function* (provide) {
let dir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-inline-"));
try {
yield* provide(dir);
} finally {
fs.rmSync(dir, { recursive: true });
}
});
}
49 changes: 49 additions & 0 deletions inline/esbuild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* esbuild plugin that applies the {@link @effectionx/inline} optimization
* at build time. Transforms all `yield*` expressions inside generator
* functions into `(yield inline(...))` calls.
*
* @example
* ```ts
* import { build } from "esbuild";
* import { inlinePlugin } from "@effectionx/inline/esbuild";
*
* await build({
* entryPoints: ["src/index.ts"],
* bundle: true,
* plugins: [inlinePlugin()],
* });
* ```
*
* @module
*/

import type { Plugin } from "esbuild";
import { readFile } from "node:fs/promises";
import { transformSource } from "./transform.ts";

export function inlinePlugin(): Plugin {
return {
name: "effectionx-inline",
setup(build) {
build.onLoad({ filter: /\.[tj]sx?$/ }, async (args) => {
let source = await readFile(args.path, "utf-8");
let { code, transformed } = transformSource(source, args.path);

if (!transformed) {
return undefined;
}

let loader = args.path.endsWith(".tsx")
? ("tsx" as const)
: args.path.endsWith(".ts")
? ("ts" as const)
: args.path.endsWith(".jsx")
? ("jsx" as const)
: ("js" as const);

return { contents: code, loader };
});
},
};
}
102 changes: 102 additions & 0 deletions inline/inline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { type Operation, run, suspend, until } from "effection";
import { describe, it } from "node:test";
import { inline } from "./mod.ts";
import { expect } from "expect";

describe("inline", () => {
it("can be used for simple operations", async () => {
let value = await run(function* () {
return yield inline(until(Promise.resolve(10)));
});
expect(value).toEqual(10);
});

it("can be used for operations with multiple yield points", async () => {
let result = await run(function* () {
let first = yield inline(
(function* () {
return yield inline(constant(5));
})(),
);
let second = yield inline(
(function* () {
return yield inline(constant(5));
})(),
);
return (first as number) + (second as number);
});
expect(result).toEqual(10);
});

it("can be used for recursive operations", async () => {
function* recurse(depth: number, total: number): Operation<number> {
if (depth > 0) {
return (yield inline(recurse(depth - 1, total + depth))) as number;
} else {
for (let i = 0; i < 10; i++) {
total += yield* until(Promise.resolve(1));
}
return total;
}
}
await expect(run(() => recurse(10, 0))).resolves.toEqual(65);
});

it("successfully halts inlined iterators", async () => {
let backout = 0;

function* recurse(depth: number, total: number): Operation<number> {
if (depth > 0) {
try {
return (yield inline(recurse(depth - 1, total + depth))) as number;
} finally {
backout += (yield inline(until(Promise.resolve(1)))) as number;
}
} else {
yield* suspend();
return 10;
}
}

let task = run(() => recurse(10, 0));

await task.halt();

expect(backout).toEqual(10);
});

it("handles unwinding when inlined iterators throw", async () => {
interface CountingError extends Error {
cause: number;
}

function* recurse(depth: number): Operation<number> {
if (depth > 0) {
try {
return (yield inline(recurse(depth - 1))) as number;
} catch (err) {
let counter = err as CountingError;
let num = (yield inline(until(Promise.resolve(1)))) as number;
counter.cause += num;
throw counter;
}
} else {
let error = Object.assign(new Error("bottom"), { cause: 0 });
throw error;
}
}

try {
await run(() => recurse(10));
throw new Error(`expected to throw, but did not`);
} catch (err) {
expect((err as CountingError).cause).toEqual(10);
}
});
});

function constant<T>(value: T): Operation<T> {
return {
[Symbol.iterator]: () => ({ next: () => ({ done: true, value }) }),
};
}
Comment thread
cowboyd marked this conversation as resolved.
Loading
Loading