Skip to content

Commit b2a9a40

Browse files
author
Taras Mankovski
committed
♻️ Move replay semantics from signals to stream-helpers
Remove `emitCurrentOnSubscribe` from `createValueSignal` — replay behavior belongs in `createSubject()`, not the signal primitive. Rewrite `createSubject()` as a resource with an active drain that tracks upstream values synchronously via a custom SignalQueueFactory queue and multicasts them through a relay signal. Late subscribers always receive the latest value, even when `signal.send()` is called within a running operation. Breaking: `createSubject()` now returns `Operation<Stream>` instead of `Stream`. Bump `@effectionx/stream-helpers` 0.8.3 → 0.9.0. Compose `createSubject` with value signal in worker's `createWorkerStatesSignal` to preserve replay behavior.
1 parent 72a97a0 commit b2a9a40

11 files changed

Lines changed: 178 additions & 88 deletions

File tree

pnpm-lock.yaml

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

signals/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { Stream } from "effection";
22

33
/**
44
* A signal is a stream with set, update, and valueOf methods.
5-
* Subscribing to a signal will yield the current value of the signal.
5+
* Subscribing to a signal yields values as they change.
6+
* Use {@link createSubject} from `@effectionx/stream-helpers` to replay
7+
* the latest value to new subscribers.
68
*/
79
export interface ValueSignal<T> extends Stream<T, void> {
810
/**

signals/value.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,13 @@ export interface CreateValueSignalOptions<T> {
1212
* Defaults to `Object.is`.
1313
*/
1414
equals?: (current: T, next: T) => boolean;
15-
16-
/**
17-
* Replays the current value to subscribers when they attach.
18-
*
19-
* This is disabled by default.
20-
*/
21-
emitCurrentOnSubscribe?: boolean;
2215
}
2316

2417
/**
2518
* Creates a value-backed signal with configurable equality semantics.
2619
*
2720
* @param initial - Initial signal value.
28-
* @param options - Equality and subscription behavior overrides.
21+
* @param options - Equality overrides.
2922
* @returns A value signal resource.
3023
*/
3124
export function createValueSignal<T>(
@@ -50,13 +43,7 @@ export function createValueSignal<T>(
5043

5144
try {
5245
yield* provide({
53-
[Symbol.iterator]: options.emitCurrentOnSubscribe
54-
? function* () {
55-
const subscription = yield* signal;
56-
signal.send(ref.current);
57-
return subscription;
58-
}
59-
: signal[Symbol.iterator],
46+
[Symbol.iterator]: signal[Symbol.iterator],
6047
set,
6148
update(updater) {
6249
return set(updater(ref.current));

stream-helpers/README.md

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -368,14 +368,19 @@ Subject helper converts any stream into a multicast stream that replays the
368368
latest value to new subscribers. It's analogous to
369369
[RxJS BehaviorSubject](https://www.learnrxjs.io/learn-rxjs/subjects/behaviorsubject).
370370
371+
Applying the subject to a stream returns a resource. Yielding that resource
372+
starts an internal drain that actively tracks the upstream, so late subscribers
373+
always receive the most recent value — even if no other subscriber has pulled
374+
it.
375+
371376
```typescript
372377
import { createSubject } from "@effectionx/stream-helpers";
373-
import { createChannel, spawn } from "effection";
378+
import { createChannel } from "effection";
374379

375380
function* example() {
376381
const subject = createSubject<number>();
377382
const channel = createChannel<number, void>();
378-
const downstream = subject(channel);
383+
const downstream = yield* subject(channel);
379384

380385
// First subscriber
381386
const sub1 = yield* downstream;
@@ -392,22 +397,8 @@ function* example() {
392397
}
393398
```
394399
395-
Use it with a pipe operator to convert any stream into a behavior subject:
396-
397-
```typescript
398-
import { createSubject, map } from "@effectionx/stream-helpers";
399-
import { pipe } from "remeda";
400-
401-
const subject = createSubject<string>();
402-
403-
const stream = pipe(
404-
source,
405-
map(function* (x) {
406-
return x.toString();
407-
}),
408-
subject,
409-
);
410-
```
400+
> **Note:** `createSubject()` returns a resource, so it must be yielded with
401+
> `yield*`. It cannot be used directly as a pipe transformer.
411402
412403
### Passthrough Tracker
413404

stream-helpers/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@effectionx/stream-helpers",
33
"description": "Type-safe stream operators like filter, map, reduce, and forEach",
4-
"version": "0.8.3",
4+
"version": "0.9.0",
55
"keywords": ["streams"],
66
"type": "module",
77
"main": "./dist/mod.js",

stream-helpers/subject.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
createChannel,
3+
createSignal,
34
type Operation,
5+
scoped,
46
type Stream,
57
type Subscription,
68
} from "effection";
@@ -29,7 +31,7 @@ describe("subject", () => {
2931

3032
upstream = createChannel();
3133

32-
downstream = subject(upstream);
34+
downstream = yield* subject(upstream);
3335
});
3436

3537
it("allows multiple subscribers", function* () {
@@ -78,7 +80,7 @@ describe("subject", () => {
7880

7981
it("yields initial value to first subscriber before any upstream values", function* () {
8082
subject = createSubject(42);
81-
downstream = subject(upstream);
83+
downstream = yield* subject(upstream);
8284

8385
const subscriber = yield* downstream;
8486
expect(yield* next(subscriber)).toEqual(42);
@@ -89,7 +91,7 @@ describe("subject", () => {
8991

9092
it("yields upstream value to late subscriber once upstream has sent", function* () {
9193
subject = createSubject(42);
92-
downstream = subject(upstream);
94+
downstream = yield* subject(upstream);
9395

9496
const subscriber1 = yield* downstream;
9597
expect(yield* next(subscriber1)).toEqual(42);
@@ -115,4 +117,39 @@ describe("subject", () => {
115117
const subscriber2 = yield* downstream;
116118
expect(yield* next(subscriber2)).toEqual("bye");
117119
});
120+
121+
it("tracks latest value even when no subscriber has pulled", function* () {
122+
const source = createSignal<number, string>();
123+
downstream = yield* createSubject<number>(0)(source);
124+
125+
const sub1 = yield* downstream;
126+
expect(yield* next(sub1)).toEqual(0);
127+
128+
// Upstream emits — sub1 does NOT pull these
129+
source.send(1);
130+
source.send(2);
131+
132+
// Late subscriber gets latest, not initial
133+
const sub2 = yield* downstream;
134+
expect(yield* next(sub2)).toEqual(2);
135+
});
136+
137+
it("continues tracking after first subscriber exits", function* () {
138+
const source = createSignal<number, string>();
139+
downstream = yield* createSubject<number>()(source);
140+
141+
// First subscriber in a scoped block — exits after reading
142+
yield* scoped(function* () {
143+
const sub = yield* downstream;
144+
source.send(1);
145+
expect(yield* next(sub)).toEqual(1);
146+
});
147+
// sub's scope is gone, but drain lives in the resource scope
148+
149+
source.send(2);
150+
151+
// New subscriber still works
152+
const sub2 = yield* downstream;
153+
expect(yield* next(sub2)).toEqual(2);
154+
});
118155
});

stream-helpers/subject.ts

Lines changed: 89 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,115 @@
1-
import type { Stream, Subscription } from "effection";
1+
import {
2+
createQueue,
3+
createSignal,
4+
resource,
5+
SignalQueueFactory,
6+
spawn,
7+
type Operation,
8+
type Stream,
9+
type Subscription,
10+
} from "effection";
211

312
/**
4-
* Converts any stream into a multicast stream that produces latest value
5-
* to new subscribers. It's designed to be analagous in function to [RxJS
6-
* BehaviorSubject](https://www.learnrxjs.io/learn-rxjs/subjects/behaviorsubject).
13+
* Converts any stream into a multicast stream that replays the latest value
14+
* to new subscribers. Analogous to
15+
* [RxJS BehaviorSubject](https://www.learnrxjs.io/learn-rxjs/subjects/behaviorsubject).
716
*
8-
* @returns A function that takes a stream and returns a multicast stream
17+
* Applying the subject to a stream returns a resource. Yielding that resource
18+
* starts an internal drain that actively tracks the upstream, so late
19+
* subscribers always receive the most recent value — even if no other
20+
* subscriber has pulled it.
21+
*
22+
* @returns A function that takes a stream and returns a resource providing
23+
* a multicast stream.
924
*
1025
* @example
1126
* ```ts
1227
* const subject = createSubject<number>();
13-
* const downstream = subject(upstream);
28+
* const downstream = yield* subject(upstream);
1429
*
15-
* const sub1 = yield* downstream; // subscribes to upstream
30+
* const sub1 = yield* downstream; // subscribes
1631
* yield* upstream.send(1);
1732
* yield* sub1.next(); // { done: false, value: 1 }
1833
*
1934
* const sub2 = yield* downstream; // late subscriber
20-
* yield* sub2.next(); // { done: false, value: 1 } - gets latest value
21-
* ```
22-
*
23-
* Use it with a pipe operator to convert any stream into a behavior subject.
24-
*
25-
* @example
26-
* ```
27-
* let source = createChannel<string, void>();
28-
* let subject = createSubject<string>();
29-
*
30-
* let pipeline = pipe([
31-
* top,
32-
* transform1,
33-
* transform2,
34-
* subject,
35-
* ]);
35+
* yield* sub2.next(); // { done: false, value: 1 } — gets latest value
3636
* ```
3737
*/
3838
export function createSubject<T>(
3939
initial?: T,
40-
): <TClose>(stream: Stream<T, TClose>) => Stream<T, TClose> {
40+
): <TClose>(stream: Stream<T, TClose>) => Operation<Stream<T, TClose>> {
4141
let current: IteratorResult<T> | undefined =
4242
typeof initial !== "undefined"
4343
? { done: false, value: initial }
4444
: undefined;
4545

46-
return <TClose>(stream: Stream<T, TClose>) => ({
47-
*[Symbol.iterator]() {
48-
let upstream = yield* stream;
46+
return <TClose>(stream: Stream<T, TClose>) =>
47+
resource<Stream<T, TClose>>(function* (provide) {
48+
const relay = createSignal<T, TClose>();
49+
let closed = false;
4950

50-
let tracking: Subscription<T, TClose> = {
51-
*next() {
52-
current = yield* upstream.next();
53-
return current;
54-
},
55-
};
51+
// Install a custom queue that updates `current` synchronously
52+
// when values arrive — before the drain task gets scheduled.
53+
// This guarantees late subscribers always see the latest value
54+
// even when signal.send() is called within a running operation.
55+
yield* SignalQueueFactory.set(<U, UClose = never>() => {
56+
const queue = createQueue<U, UClose>();
57+
return {
58+
...queue,
59+
add(value: U) {
60+
current = { done: false, value: value as unknown as T };
61+
queue.add(value);
62+
},
63+
close(value: UClose) {
64+
current = { done: true, value } as unknown as IteratorResult<T>;
65+
closed = true;
66+
queue.close(value);
67+
},
68+
};
69+
});
5670

57-
let iterator: Subscription<T, TClose> = current
58-
? {
59-
*next() {
60-
iterator = tracking;
61-
return current!;
62-
},
71+
// Subscribe to upstream eagerly so its queue starts buffering
72+
// before provide() hands back control to callers.
73+
const upstream = yield* stream;
74+
75+
// Drain owns the read loop that multicasts values to relay subscribers.
76+
// Lives in the resource scope — outlives any individual subscriber.
77+
yield* spawn(function* () {
78+
let result = yield* upstream.next();
79+
while (!result.done) {
80+
relay.send(result.value);
81+
result = yield* upstream.next();
82+
}
83+
relay.close(result.value);
84+
});
85+
86+
yield* provide({
87+
*[Symbol.iterator]() {
88+
// Post-close: Effection signals are stateless — subscribing after
89+
// relay.close() would hang. Return captured close result directly.
90+
if (closed && current) {
91+
let snapshot = current;
92+
return {
93+
*next() {
94+
return snapshot as IteratorResult<T, TClose>;
95+
},
96+
};
6397
}
64-
: tracking;
6598

66-
return {
67-
next: () => iterator.next(),
68-
};
69-
},
70-
});
99+
let subscription: Subscription<T, TClose> = yield* relay;
100+
let snapshot = current;
101+
let replayed = !snapshot;
102+
103+
return {
104+
*next() {
105+
if (!replayed) {
106+
replayed = true;
107+
return snapshot! as IteratorResult<T, TClose>;
108+
}
109+
return yield* subscription.next();
110+
},
111+
};
112+
},
113+
});
114+
});
71115
}

worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"sideEffects": false,
3030
"dependencies": {
3131
"@effectionx/signals": "workspace:*",
32+
"@effectionx/stream-helpers": "workspace:*",
3233
"@effectionx/timebox": "workspace:*",
3334
"web-worker": "^1"
3435
},

worker/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
{
1414
"path": "../signals"
1515
},
16+
{
17+
"path": "../stream-helpers"
18+
},
1619
{
1720
"path": "../timebox"
1821
},

0 commit comments

Comments
 (0)