Skip to content

feat(state-api): add @effectionx/state-api with useState#193

Open
taras wants to merge 4 commits intomainfrom
feat/state-api
Open

feat(state-api): add @effectionx/state-api with useState#193
taras wants to merge 4 commits intomainfrom
feat/state-api

Conversation

@taras
Copy link
Copy Markdown
Member

@taras taras commented Mar 14, 2026

Motivation

Add a reactive state container primitive to EffectionX that supports typed reducer actions and per-container middleware. This fills a gap between the existing @effectionx/signals (synchronous mutations, no middleware) and the API pattern used by fetch/process/fs (stateless operations).

useState provides a reducer-like API inspired by React's useReducer and TC39 Signals, where state transitions are named, type-safe, and interceptable.

Approach

New package @effectionx/state-api with a single entry point: useState<T>(initial, reducers?).

  • Each useState() call creates its own createApi instance from @effectionx/context-api, so middleware installed on one container does not affect others.
  • Built-in operations: set(value), update(fn), get() — all return Operation<T> for the resulting state.
  • User-defined reducer actions follow (state, ...args) => newState; the state argument is injected and callers pass only the remaining args.
  • State<T> implements Stream<T, void> for subscribing to changes via each.
  • Middleware uses around() from @effectionx/context-api, keeping the package on the monorepo's stable Effection version instead of pinning the repo to effection/experimental.

Validation

  • node --env-file=.env --test state-api/state.test.ts
  • pnpm check:tsrefs
  • pnpm lint
  • pnpm fmt:check
  • pnpm check

Summary by CodeRabbit

  • New Features

    • Added a new State API package providing reactive state management with useState, typed reducers, stream subscriptions, and middleware support.
  • Documentation

    • Added comprehensive State API docs with usage examples, middleware patterns, and scoping semantics.
  • Tests

    • Added extensive test coverage validating state operations, streams, reducers, and middleware behavior.
  • Chores

    • Registered the State API package in the workspace and added its package manifest and build configuration.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b69c1d13-ba02-4343-a146-63ab15a1b2f5

📥 Commits

Reviewing files that changed from the base of the PR and between a6c6413 and 97338c9.

📒 Files selected for processing (1)
  • state-api/state.ts

📝 Walkthrough

Walkthrough

Adds a new state-api package to the monorepo: implements a typed, middleware-capable reactive state container built on Effection, includes tests, documentation, TypeScript project config, and package manifest; and registers the package in workspace/tsconfig.

Changes

Cohort / File(s) Summary
Workspace & TS Config
pnpm-workspace.yaml, tsconfig.json, state-api/tsconfig.json
Registers state-api in the pnpm workspace and TS project references; configures state-api build outputs and excludes tests.
Package Manifest
state-api/package.json
Adds @effectionx/state-api package manifest with exports map (types/development/import/default), peer/dev/workspace deps, files to publish, and ESM config. Review export map and peerDependency bounds.
Core Implementation
state-api/state.ts
New implementation of useState providing Stream-based state, set/update/get, typed reducers, reserved-name validation, middleware via around(), signal lifecycle/cleanup, and API construction using createApi. High-density logic — focus review here.
Public Exports
state-api/mod.ts
Re-exports useState, State, and ReducerMap from state.ts.
Tests
state-api/state.test.ts
Comprehensive test suite covering basic ops, stream emissions, reducers, middleware behavior, and scoping.
Docs
state-api/README.md
Adds usage documentation with examples for basic state, reducers, streams, and middleware semantics.

Sequence Diagram

sequenceDiagram
    actor User
    participant useState as "useState Operation"
    participant Signal as "Effection Signal"
    participant API as "createApi / Core Handlers"
    participant Middleware as "around() Middleware"
    participant Subscriber as "Stream Subscriber"

    User->>useState: invoke useState(initial, reducers?)
    useState->>Signal: createSignal()
    useState->>API: build core handlers (set/update/get + reducers)
    useState->>User: return State object (Stream + ops + around)

    User->>State: call operation (e.g., set(newValue))
    State->>Middleware: around() intercepts operation
    Middleware->>API: invoke core handler (possibly transformed)
    API->>Signal: update internal state ref and send value
    Signal->>Subscriber: emit new state value
    Subscriber->>User: receive update
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~55 minutes

Possibly related PRs

Suggested reviewers

  • cowboyd

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Policy Compliance ❓ Inconclusive Policy files (.policies/ directory and referenced markdown files) do not exist in the repository, making policy validation impossible. Create .policies/ directory with index.md, package-json-metadata.md, version-bump.md, and no-agent-marketing.md files to enable policy validation.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: adding a new state-api package with the useState feature to EffectionX.
Description check ✅ Passed The description follows the template structure with clear Motivation and Approach sections, explaining both the problem and the implementation details comprehensively.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/state-api

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/thefrontside/effectionx/@effectionx/state-api@193

commit: 97338c9

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@state-api/README.md`:
- Around line 24-53: The examples use yield* and identifiers like useState,
todos, and counter without showing the surrounding generator runtime, making
them non-copy/paste; either wrap each example in a complete runnable snippet
(e.g., run(function* () { /* example code using yield*, useState, todos, counter
*/ })) or add a one-line header to each block stating "continuation from
previous snippet" and include the minimal setup (e.g., const todos = yield*
useState(...) or a comment like // assumes run(generator) and todos already
defined) so readers can run examples directly; update the Typed reducers block
and the other affected blocks (mentions of todos, counter, yield*) accordingly.

In `@state-api/state.test.ts`:
- Line 2: Replace the timing-based sleep(0) test sync with a deterministic
"subscriber ready" resolver: in the tests that import sleep, withResolvers,
spawn, and each (notably the update() and reducer-stream tests referenced),
create a resolver via withResolvers() (e.g. const { resolve: markReady, promise:
ready } = withResolvers()), pass or expose that resolver to the subscriber
setup, have the subscriber call markReady() once it is subscribed/consuming, and
have the producer/creator await ready before emitting/dispatching; remove all
sleep(0) calls and use this ready promise to gate producer actions so tests
deterministically synchronize without timing-based sleeps.

In `@state-api/state.ts`:
- Around line 154-165: The reducer registration loop in state.ts assigns user
reducers into core using their keys, which allows user reducers named
set/update/get/around to shadow built-in operations; update the registration
code that iterates Object.keys(reducers) (the block that assigns core[key] =
function* (...) and the equivalent second reducer-registration block) to
validate each key against a denylist ["set","update","get","around"] and reject
— e.g., throw a clear Error — when a user attempts to register one of those
reserved names so built-in operations (core, api.operations) cannot be silently
overridden.
- Around line 171-177: The state object currently assigns the iterator by
copying the function reference ([Symbol.iterator]: signal[Symbol.iterator])
which loses the receiver and breaks stream semantics; replace that assignment
with a generator method on state that delegates to signal (i.e., implement a
*[Symbol.iterator]() { yield* signal; } style delegation) so the iterator runs
with the correct receiver and follows the established
.policies/stateless-streams.md pattern; update the code that builds state (the
object literal containing [Symbol.iterator], signal, and api.operations) to use
the generator method instead of copying signal[Symbol.iterator].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b250e5d3-d6a3-4e86-9c48-9e66e21e8bd7

📥 Commits

Reviewing files that changed from the base of the PR and between 35b018c and b134887.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (11)
  • effect-ts/effect-runtime.ts
  • effect-ts/package.json
  • package.json
  • pnpm-workspace.yaml
  • state-api/README.md
  • state-api/mod.ts
  • state-api/package.json
  • state-api/state.test.ts
  • state-api/state.ts
  • state-api/tsconfig.json
  • tsconfig.json

@@ -0,0 +1,316 @@
import { describe, it } from "@effectionx/bdd";
import { each, scoped, sleep, spawn, withResolvers } from "effection";
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace sleep(0) synchronization with deterministic readiness signaling.

These waits are timing-based and violate the no-sleep test-sync policy. Gate producer actions on an explicit “subscriber ready” resolver instead.

🔧 Deterministic pattern to apply (example)
-import { each, scoped, sleep, spawn, withResolvers } from "effection";
+import { each, scoped, spawn, withResolvers } from "effection";

 it("emits new state on set()", function* () {
   const counter = yield* useState(0);
   const values: number[] = [];
   const { resolve, operation } = withResolvers<void>();
+  const { resolve: subscriberReady, operation: subscriberReadyOp } =
+    withResolvers<void>();

   yield* spawn(function* () {
-    for (const value of yield* each(counter)) {
+    const stream = yield* each(counter);
+    subscriberReady();
+    for (const value of stream) {
       values.push(value);
       if (values.length === 3) {
         resolve();
       }
       yield* each.next();
     }
   });

-  // allow subscriber to establish
-  yield* sleep(0);
+  yield* subscriberReadyOp;

   yield* counter.set(1);
   yield* counter.set(2);
   yield* counter.set(3);

Apply the same pattern to the update() and reducer-stream tests currently using sleep(0).

As per coding guidelines: ".policies/no-sleep-test-sync.md - Tests must not use sleep() for waiting; use deterministic helpers" and "**/*.test.{ts,tsx}: Avoid sleep() for test synchronization".

Also applies to: 60-62, 86-87, 131-132

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state-api/state.test.ts` at line 2, Replace the timing-based sleep(0) test
sync with a deterministic "subscriber ready" resolver: in the tests that import
sleep, withResolvers, spawn, and each (notably the update() and reducer-stream
tests referenced), create a resolver via withResolvers() (e.g. const { resolve:
markReady, promise: ready } = withResolvers()), pass or expose that resolver to
the subscriber setup, have the subscriber call markReady() once it is
subscribed/consuming, and have the producer/creator await ready before
emitting/dispatching; remove all sleep(0) calls and use this ready promise to
gate producer actions so tests deterministically synchronize without
timing-based sleeps.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The no-sleep policy explicitly allows sleep(0) for yielding control (line 14). The suggested withResolvers pattern doesn't work with effection's each() — you can't split yield* each(stream) from the for...of to insert a readiness signal. Added comments clarifying policy compliance.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

Let me verify the policy file to confirm this.


🏁 Script executed:

#!/bin/bash
cat -n .policies/no-sleep-test-sync.md

Length of output: 7434


@taras, you're right on both counts — I apologize for the incorrect review comment.

The policy table at line 14 explicitly lists "Yielding control (no delay)" → "sleep(0) is acceptable", and there's even a dedicated compliant example for it. The sleep(0) calls in these tests are precisely that pattern: yielding control to allow the spawned subscriber to run, not waiting for an async result.

Additionally, my suggested withResolvers workaround was unworkable — yield* each(counter) is consumed directly in the for...of header; there's no API surface to intercept between subscription and the start of iteration to insert a readiness signal.


✏️ Learnings added
Learnt from: taras
Repo: thefrontside/effectionx PR: 193
File: state-api/state.test.ts:2-2
Timestamp: 2026-03-15T00:10:37.158Z
Learning: In `state-api/state.test.ts` (and Effection tests generally), `sleep(0)` used before emitting values to a spawned subscriber is explicitly allowed by the no-sleep-test-sync policy (line 14). The policy only forbids `sleep(ms > 0)` used to *wait for* async results; `sleep(0)` for yielding control is a listed compliant pattern.

Learnt from: taras
Repo: thefrontside/effectionx PR: 193
File: state-api/state.test.ts:2-2
Timestamp: 2026-03-15T00:10:37.158Z
Learning: In Effection, `each()` cannot be split from its `for...of` loop to insert a readiness signal. The expression `for (const value of yield* each(stream))` is atomic — there is no API hook between subscription setup and the start of iteration, so the `withResolvers` "subscriber ready" pattern is not applicable to `each()`-based stream consumers.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.test.{ts,tsx} : Import test utilities from 'effectionx/bdd' when needed in test files

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.test.ts : Import test utilities from `effectionx/bdd` when needed in test files

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.test.{ts,tsx} : Avoid 'sleep()' for test synchronization in accordance with No-Sleep Test Sync policy

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.{ts,tsx} : Use structured concurrency patterns with spawn and scope in Effection code

Learnt from: taras
Repo: thefrontside/effectionx PR: 179
File: durable-streams/each.ts:81-82
Timestamp: 2026-03-07T00:55:27.391Z
Learning: In `durable-streams/each.ts`, the module-level `activeState` variable shared between `durableEach` and `durableEach.next` is an intentional design decision. Durable execution is single-threaded (cooperative generators), so there is no concurrent access risk within a single coroutine chain. Using Effection context is not viable here because both `durableEach` and `durableEach.next` are wrapped in `ephemeral()`, which gives each its own child scope, making context invisible across them. A TODO is in place to revisit if Effection adds context that spans ephemeral scope boundaries.

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.{ts,tsx} : Use structured concurrency patterns (spawn, scope) in Effection code

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-02-18T13:51:01.407Z
Learning: Applies to **/*.{test,spec}.{ts,tsx,js,jsx} : Deterministic Tests - Write tests that produce consistent results regardless of execution order or timing (Experimental/Advisory)

Learnt from: taras
Repo: thefrontside/effectionx PR: 179
File: durable-streams/structured-concurrency.test.ts:262-279
Timestamp: 2026-03-07T00:55:58.397Z
Learning: In `durable-streams/structured-concurrency.test.ts` (and the durable-streams package generally), race tests using `durableRace` with immediately-resolving promises are deterministic under Effection's cooperative scheduling model. Effection processes spawned tasks in FIFO order, so the first branch (`root.0`) always wins when it completes in fewer effect steps than subsequent branches. The winner is structurally determined by effect count, not by wall-clock timing or scheduler luck.

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.{ts,tsx} : Use `*[Symbol.iterator]` pattern for reusable stream operations in Effection (see Stateless Streams policy)

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.{ts,tsx} : Use '*[Symbol.iterator]' pattern for reusable stream operations in accordance with Stateless Streams policy

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-02-18T13:51:01.407Z
Learning: Applies to **/*.{ts,tsx,js,jsx} : Structured Concurrency - Manage concurrent operations with explicit scoping and cancellation (Experimental/Advisory)

Learnt from: taras
Repo: thefrontside/effectionx PR: 180
File: durable-effects/node-runtime.ts:36-49
Timestamp: 2026-03-07T03:20:27.616Z
Learning: In `durable-effects/node-runtime.ts`, `effectionx/process` does not support a timeout parameter. The `timeout` field in `ExecOptions` is intentionally not forwarded to the underlying `exec()` call. Cancellation is instead handled via Effection's structured concurrency — when the parent scope is torn down, the child process is cleaned up automatically. A documentation comment was added to capture this limitation.

Comment on lines +171 to +177
const state = {
// Stream interface
[Symbol.iterator]: signal[Symbol.iterator],
// Middleware
around: api.around,
// Spread all operations (set, update, valueOf, + reducers)
...api.operations,
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 14, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Demonstrate why copying a generator method by reference changes its receiver.
node <<'NODE'
const source = {
  label: "signal",
  *[Symbol.iterator]() {
    yield this.label;
  },
};

const wrapped = {
  [Symbol.iterator]: source[Symbol.iterator],
};

console.log([...source]);   // ["signal"]
console.log([...wrapped]);  // [undefined]
NODE

Repository: thefrontside/effectionx

Length of output: 93


🏁 Script executed:

cat -n state-api/state.ts | head -200 | tail -50

Repository: thefrontside/effectionx

Length of output: 1890


🏁 Script executed:

head -5 state-api/state.ts && wc -l state-api/state.ts

Repository: thefrontside/effectionx

Length of output: 159


🏁 Script executed:

cat .policies/stateless-streams.md

Repository: thefrontside/effectionx

Length of output: 3788


🏁 Script executed:

rg "signal" state-api/state.ts -B 5 -A 5 | head -80

Repository: thefrontside/effectionx

Length of output: 1880


🏁 Script executed:

rg "createSignal" state-api/state.ts -B 2 -A 2

Repository: thefrontside/effectionx

Length of output: 347


🏁 Script executed:

fd -e ts -e tsx -name "*.ts" | xargs grep -l "export.*createSignal" | head -5

Repository: thefrontside/effectionx

Length of output: 297


🏁 Script executed:

rg "interface.*Stream|type.*Stream" --type ts | grep -v node_modules | head -20

Repository: thefrontside/effectionx

Length of output: 1473


🏁 Script executed:

rg "export type State" state-api/state.ts -A 3

Repository: thefrontside/effectionx

Length of output: 163


🏁 Script executed:

rg "export interface.*Stream" --type ts | head -10

Repository: thefrontside/effectionx

Length of output: 313


Use *[Symbol.iterator]() generator method instead of copying the method reference.

The current [Symbol.iterator]: signal[Symbol.iterator] pattern loses receiver context. When the copied method executes, this points to state (due to how it's spread), not signal, breaking the stream protocol. Wrap the delegation in a generator method to preserve correct semantics:

Suggested fix
     const state = {
       // Stream interface
-      [Symbol.iterator]: signal[Symbol.iterator],
+      *[Symbol.iterator]() {
+        yield* signal;
+      },
       // Middleware
       around: api.around,

This aligns with .policies/stateless-streams.md (Established Policy) which requires stream operations to use the *[Symbol.iterator]() pattern for proper reusability and receiver handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state-api/state.ts` around lines 171 - 177, The state object currently
assigns the iterator by copying the function reference ([Symbol.iterator]:
signal[Symbol.iterator]) which loses the receiver and breaks stream semantics;
replace that assignment with a generator method on state that delegates to
signal (i.e., implement a *[Symbol.iterator]() { yield* signal; } style
delegation) so the iterator runs with the correct receiver and follows the
established .policies/stateless-streams.md pattern; update the code that builds
state (the object literal containing [Symbol.iterator], signal, and
api.operations) to use the generator method instead of copying
signal[Symbol.iterator].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Tried *[Symbol.iterator]() { yield* signal; } — it breaks stream semantics. each() expects the iterator to return a Subscription with .next(), but yield* signal yields the subscription's values directly. The reference copy pattern matches all existing signal packages (boolean.ts, array.ts, set.ts) and works correctly because createSignal's iterator is a closure, not this-dependent.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how createSignal's iterator is implemented in effection
rg "createSignal|Symbol.iterator" --type ts -n -A 3 -B 3 | grep -v node_modules | head -80

# Check existing signal packages for the reference copy pattern taras mentions
for f in boolean.ts array.ts set.ts; do
  fd -e ts "$f" | xargs grep -l "Symbol.iterator" 2>/dev/null | head -3
done

# Look at how each() and Subscription work in effection
rg "Subscription|\.next\(\)" --type ts -n | grep -v node_modules | head -30

Length of output: 6129


@taras, you're right — I apologize for the incorrect suggestion. The script output confirms the established codebase pattern: websocket/websocket.ts (line 162) and worker/worker.ts (line 305) both use the identical [Symbol.iterator]: signal[Symbol.iterator] reference copy. Since createSignal's iterator is closure-based rather than this-dependent, the copy is safe. Using *[Symbol.iterator]() { yield* signal; } would incorrectly bypass the Subscription object and directly yield values, breaking the each() protocol which expects a Subscription with .next(). The current code is correct.


✏️ Learnings added
Learnt from: taras
Repo: thefrontside/effectionx PR: 193
File: state-api/state.ts:177-183
Timestamp: 2026-03-15T00:10:44.596Z
Learning: In Effection's `createSignal`, the `[Symbol.iterator]` method is a closure-based implementation and is NOT `this`-dependent. It is safe and correct to copy the reference directly: `[Symbol.iterator]: signal[Symbol.iterator]`. This is the established codebase convention used in `websocket/websocket.ts` and `worker/worker.ts`. Do NOT suggest replacing it with `*[Symbol.iterator]() { yield* signal; }` — that pattern bypasses the `Subscription` wrapper and breaks `each()`, which expects a `Subscription` object with a `.next()` method, not directly yielded values.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: taras
Repo: thefrontside/effectionx PR: 179
File: durable-streams/each.ts:81-82
Timestamp: 2026-03-07T00:55:27.391Z
Learning: In `durable-streams/each.ts`, the module-level `activeState` variable shared between `durableEach` and `durableEach.next` is an intentional design decision. Durable execution is single-threaded (cooperative generators), so there is no concurrent access risk within a single coroutine chain. Using Effection context is not viable here because both `durableEach` and `durableEach.next` are wrapped in `ephemeral()`, which gives each its own child scope, making context invisible across them. A TODO is in place to revisit if Effection adds context that spans ephemeral scope boundaries.

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.{ts,tsx} : Use '*[Symbol.iterator]' pattern for reusable stream operations in accordance with Stateless Streams policy

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.{ts,tsx} : Use `*[Symbol.iterator]` pattern for reusable stream operations in Effection (see Stateless Streams policy)

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.{ts,tsx} : Prefer 'Operation<T>' for async operations in Effection

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.{ts,tsx} : Prefer `Operation<T>` for async operations in Effection code

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-08T18:16:06.004Z
Learning: Applies to **/*.{ts,tsx} : Use structured concurrency patterns (spawn, scope) in Effection code

Learnt from: CR
Repo: thefrontside/effectionx PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-23T17:32:37.408Z
Learning: Applies to **/*.{ts,tsx} : Use structured concurrency patterns with spawn and scope in Effection code

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@state-api/state.test.ts`:
- Around line 309-329: The test expectation is inverted: because createApi
composes middleware so each new around() wraps the prior one, the most recently
added middleware runs first; update the assertion in the "chains multiple
middleware" test to expect ["second","first"] (and optionally adjust the test
description) for the useState counter.around / counter.set case so it matches
the middleware composition behavior implemented in createApi.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 36de19c6-bd8f-43f4-9a18-ac80bcc4ee64

📥 Commits

Reviewing files that changed from the base of the PR and between b134887 and 0cc80fe.

📒 Files selected for processing (3)
  • state-api/README.md
  • state-api/state.test.ts
  • state-api/state.ts

taras added 2 commits March 16, 2026 15:40
Reactive state container with typed reducer actions and middleware
support, built on Effection's experimental createApi.

- useState<T>(initial) for simple state with set/update/get
- useState<T, R>(initial, reducers) for typed reducer actions
- All operations return Operation<T> (the resulting state)
- State<T> implements Stream<T> for subscribing to changes
- Per-container middleware via around() for any operation
- Middleware is scoped and does not leak across scope boundaries
- Guard against reserved reducer names (set, update, get, around)
  that would shadow built-in operations
- Add test for reserved name rejection
- Add policy-compliance comments on sleep(0) usage in stream tests
- Make README examples self-contained with run() wrappers

Note: kept [Symbol.iterator] reference copy pattern — it matches all
existing signal packages and works correctly because createSignal's
iterator is a closure. The *[Symbol.iterator]() delegation pattern
breaks stream semantics with each().
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@state-api/package.json`:
- Around line 25-27: The peer dependency range for effection in state-api's
package.json is too restrictive; update the "peerDependencies" entry for
"effection" to the repository standard range (e.g., "^3 || ^4") so it matches
other packages and restores broader compatibility—modify the "effection" value
under peerDependencies in package.json accordingly.

In `@state-api/state.ts`:
- Around line 182-183: Update the misleading inline comment near the spread of
api.operations: change the mention of the built-in operation name from "valueOf"
to "get" so it correctly reads something like "// Spread all operations (set,
update, get, + reducers)". Locate the comment adjacent to the spread of
api.operations and edit only the comment text; do not modify the code behavior
of the api.operations spread or any related function names.
- Around line 156-166: Validate the incoming reducers before wiring them: ensure
`reducers` is a non-null object (not an array) and that every property value is
a function, otherwise throw a clear initialization Error naming the invalid key
and its type; update the block that iterates `Object.keys(reducers)` (where
`core[key] = function* (...args: any[]): Operation<T>` and `ref.current =
reducer(ref.current, ...args)` are set) to perform these checks first and only
then bind the generator wrappers, so invalid shapes fail fast with descriptive
messages referencing the offending reducer key.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f6a6192d-88b2-4bda-9b86-1b3f9280c2bb

📥 Commits

Reviewing files that changed from the base of the PR and between 0cc80fe and 53f112f.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (9)
  • package.json
  • pnpm-workspace.yaml
  • state-api/README.md
  • state-api/mod.ts
  • state-api/package.json
  • state-api/state.test.ts
  • state-api/state.ts
  • state-api/tsconfig.json
  • tsconfig.json

Comment on lines +156 to +166
if (reducers) {
for (const key of Object.keys(reducers)) {
if (reserved.has(key)) {
throw new Error(
`Reducer name "${key}" is reserved. Built-in operations (set, update, get, around) cannot be overridden.`,
);
}
const reducer = reducers[key];
// biome-ignore lint/suspicious/noExplicitAny: reducer args are open-ended
core[key] = function* (...args: any[]): Operation<T> {
ref.current = reducer(ref.current, ...args);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate reducer map shape and entries at initialization.

At Line 156, any truthy reducers value is accepted, and invalid entries fail later when invoked. Validate that reducers is an object map and each value is a function before wiring core to fail early with clearer errors.

Suggested hardening
-    if (reducers) {
-      for (const key of Object.keys(reducers)) {
+    if (reducers !== undefined) {
+      if (reducers === null || typeof reducers !== "object" || Array.isArray(reducers)) {
+        throw new TypeError("`reducers` must be an object of reducer functions");
+      }
+      for (const key of Object.keys(reducers)) {
         if (reserved.has(key)) {
           throw new Error(
             `Reducer name "${key}" is reserved. Built-in operations (set, update, get, around) cannot be overridden.`,
           );
         }
-        const reducer = reducers[key];
+        const reducer = reducers[key];
+        if (typeof reducer !== "function") {
+          throw new TypeError(`Reducer "${key}" must be a function`);
+        }
         // biome-ignore lint/suspicious/noExplicitAny: reducer args are open-ended
         core[key] = function* (...args: any[]): Operation<T> {
           ref.current = reducer(ref.current, ...args);

As per coding guidelines: .policies/correctness-invariants.md - “Validate inputs; test success/error/halt paths”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state-api/state.ts` around lines 156 - 166, Validate the incoming reducers
before wiring them: ensure `reducers` is a non-null object (not an array) and
that every property value is a function, otherwise throw a clear initialization
Error naming the invalid key and its type; update the block that iterates
`Object.keys(reducers)` (where `core[key] = function* (...args: any[]):
Operation<T>` and `ref.current = reducer(ref.current, ...args)` are set) to
perform these checks first and only then bind the generator wrappers, so invalid
shapes fail fast with descriptive messages referencing the offending reducer
key.

Comment on lines +182 to +183
// Spread all operations (set, update, valueOf, + reducers)
...api.operations,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix stale built-in operation name in the comment.

At Line 182, the comment says valueOf, but the built-in operation is get. This is misleading during maintenance.

Suggested comment fix
-      // Spread all operations (set, update, valueOf, + reducers)
+      // Spread all operations (set, update, get, + reducers)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Spread all operations (set, update, valueOf, + reducers)
...api.operations,
// Spread all operations (set, update, get, + reducers)
...api.operations,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state-api/state.ts` around lines 182 - 183, Update the misleading inline
comment near the spread of api.operations: change the mention of the built-in
operation name from "valueOf" to "get" so it correctly reads something like "//
Spread all operations (set, update, get, + reducers)". Locate the comment
adjacent to the spread of api.operations and edit only the comment text; do not
modify the code behavior of the api.operations spread or any related function
names.

Copy link
Copy Markdown
Member

@cowboyd cowboyd left a comment

Choose a reason for hiding this comment

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

Seems quite legit!

The only problem is that there are way to many any ignores for it not to be a code smell. Something is not quite right with the types if there has to be that many. Usually, it means that there is either a lurking incoherence in the type model, or that the number of code-paths need to be reduced. Are we sure that these are necessary?

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@state-api/state.ts`:
- Around line 149-160: The reducer registration allows a user key of "__proto__"
which will mutate core's prototype and silently drop the reducer; update the
reserved names logic by adding "__proto__" to the reserved Set (the variable
reserved) or otherwise block keys equal to "__proto__" before assigning into
core (see the reducers loop and the core[key] = ... assignment) so that
"__proto__" cannot be registered as a reducer and the prototype cannot be
mutated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6a7a6824-c1f0-41c5-9733-6ec3046a3e61

📥 Commits

Reviewing files that changed from the base of the PR and between 53f112f and a6c6413.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (3)
  • state-api/package.json
  • state-api/state.ts
  • state-api/tsconfig.json

Comment on lines +149 to +160
const reserved = new Set(["set", "update", "get", "around"]);
if (reducers) {
for (const key of Object.keys(reducers)) {
if (reserved.has(key)) {
throw new Error(
`Reducer name "${key}" is reserved. Built-in operations (set, update, get, around) cannot be overridden.`,
);
}
const reducer = reducers[key];
// biome-ignore lint/suspicious/noExplicitAny: reducer args are open-ended
core[key] = function* (...args: any[]): Operation<T> {
ref.current = reducer(ref.current, ...args);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

node <<'NODE'
const core = {
  set() {},
  update() {},
  get() {},
};

const reducer = function reducer() {};
core["__proto__"] = reducer;

console.log({
  ownKeys: Object.keys(core),
  hasOwnProto: Object.prototype.hasOwnProperty.call(core, "__proto__"),
  prototypeMutated: Object.getPrototypeOf(core) === reducer,
});
NODE

Repository: thefrontside/effectionx

Length of output: 156


Add __proto__ to the reserved reducer names.

At line 159, core[key] = ... writes user-defined reducer names into a plain object. If key === "__proto__", the special property setter mutates core's prototype instead of creating an own property, so the reducer operation is never registered and silently disappears at runtime.

Fix
-    const reserved = new Set(["set", "update", "get", "around"]);
+    const reserved = new Set(["set", "update", "get", "around", "__proto__"]);
     if (reducers) {
       for (const key of Object.keys(reducers)) {
         if (reserved.has(key)) {
           throw new Error(
-            `Reducer name "${key}" is reserved. Built-in operations (set, update, get, around) cannot be overridden.`,
+            `Reducer name "${key}" is reserved. Built-in operations and special object keys cannot be overridden.`,
           );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reserved = new Set(["set", "update", "get", "around"]);
if (reducers) {
for (const key of Object.keys(reducers)) {
if (reserved.has(key)) {
throw new Error(
`Reducer name "${key}" is reserved. Built-in operations (set, update, get, around) cannot be overridden.`,
);
}
const reducer = reducers[key];
// biome-ignore lint/suspicious/noExplicitAny: reducer args are open-ended
core[key] = function* (...args: any[]): Operation<T> {
ref.current = reducer(ref.current, ...args);
const reserved = new Set(["set", "update", "get", "around", "__proto__"]);
if (reducers) {
for (const key of Object.keys(reducers)) {
if (reserved.has(key)) {
throw new Error(
`Reducer name "${key}" is reserved. Built-in operations and special object keys cannot be overridden.`,
);
}
const reducer = reducers[key];
// biome-ignore lint/suspicious/noExplicitAny: reducer args are open-ended
core[key] = function* (...args: any[]): Operation<T> {
ref.current = reducer(ref.current, ...args);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@state-api/state.ts` around lines 149 - 160, The reducer registration allows a
user key of "__proto__" which will mutate core's prototype and silently drop the
reducer; update the reserved names logic by adding "__proto__" to the reserved
Set (the variable reserved) or otherwise block keys equal to "__proto__" before
assigning into core (see the reducers loop and the core[key] = ... assignment)
so that "__proto__" cannot be registered as a reducer and the prototype cannot
be mutated.

@taras taras requested a review from cowboyd March 27, 2026 01:08
@taras
Copy link
Copy Markdown
Member Author

taras commented Mar 27, 2026

@cowboyd how does this version look?

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.

2 participants