From ea15c2773d4ec4077fc131f03bab280ec4932cc7 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 12 Apr 2026 11:42:59 -0600 Subject: [PATCH 1/5] Updated docs and better argument validation to BehaviorSubject --- behavior-subject/deno.json | 2 +- behavior-subject/mod.test.ts | 38 +++++++++++++++++++++++++++++------- behavior-subject/mod.ts | 17 +++++++++++----- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/behavior-subject/deno.json b/behavior-subject/deno.json index d31151c..98586fe 100644 --- a/behavior-subject/deno.json +++ b/behavior-subject/deno.json @@ -1,6 +1,6 @@ { "name": "@observable/behavior-subject", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "exports": "./mod.ts", "publish": { "exclude": ["*.test.ts"] } diff --git a/behavior-subject/mod.test.ts b/behavior-subject/mod.test.ts index 181a49a..d546950 100644 --- a/behavior-subject/mod.test.ts +++ b/behavior-subject/mod.test.ts @@ -10,17 +10,17 @@ Deno.test("BehaviorSubject.toString should be '[object BehaviorSubject]'", () => assertStrictEquals(`${new BehaviorSubject(2)}`, "[object BehaviorSubject]"); }); -Deno.test("AsyncSubject.constructor should be frozen", () => { +Deno.test("BehaviorSubject.constructor should be frozen", () => { // Arrange / Act / Assert assertStrictEquals(Object.isFrozen(BehaviorSubject), true); }); -Deno.test("AsyncSubject should be frozen", () => { +Deno.test("BehaviorSubject should be frozen", () => { // Arrange / Act / Assert assertStrictEquals(Object.isFrozen(new BehaviorSubject(2)), true); }); -Deno.test("AsyncSubject.prototype should be frozen", () => { +Deno.test("BehaviorSubject.prototype should be frozen", () => { // Arrange / Act / Assert assertStrictEquals(Object.isFrozen(BehaviorSubject.prototype), true); }); @@ -106,12 +106,27 @@ Deno.test("BehaviorSubject.next should emit value to observers", () => { subject.next("foo"); // Assert - assertEquals(notifications, [ - ["next", "initial"], - ["next", "foo"], - ]); + assertEquals(notifications, [["next", "initial"], ["next", "foo"]]); }); +Deno.test( + "BehaviorSubject.next should allow empty next when created with void type", + () => { + // Arrange + const subject = new BehaviorSubject(undefined); + const notifications: Array> = []; + pipe(subject, materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Act + subject.next(); + + // Assert + assertEquals(notifications, [["next", undefined], ["next", undefined]]); + }, +); + Deno.test( "BehaviorSubject.next should store value for late observers", () => { @@ -130,6 +145,15 @@ Deno.test( }, ); +Deno.test("BehaviorSubject.throw should throw if called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => new BehaviorSubject("initial").throw(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + Deno.test("BehaviorSubject.throw should pass through this subject", () => { // Arrange const error = new Error("test error"); diff --git a/behavior-subject/mod.ts b/behavior-subject/mod.ts index df684a9..4c9879a 100644 --- a/behavior-subject/mod.ts +++ b/behavior-subject/mod.ts @@ -12,7 +12,7 @@ export type BehaviorSubject = Subject; export interface BehaviorSubjectConstructor { /** * Creates and returns an object that acts as a [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that keeps track of its current - * value and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon + * {@linkcode value} and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon * [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). * @example * ```ts @@ -74,8 +74,12 @@ export const BehaviorSubject: BehaviorSubjectConstructor = class { } next(value: Value): void { - if (this instanceof BehaviorSubject) this.#subject.next(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!(this instanceof BehaviorSubject)) { + throw new TypeError(`'this' is not instanceof '${stringTag}'`); + } + // No arguments.length check because Value may be void, making next() with no args valid. + + this.#subject.next(value); } return(): void { @@ -84,8 +88,11 @@ export const BehaviorSubject: BehaviorSubjectConstructor = class { } throw(value: unknown): void { - if (this instanceof BehaviorSubject) this.#subject.throw(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!(this instanceof BehaviorSubject)) { + throw new TypeError(`'this' is not instanceof '${stringTag}'`); + } + if (!arguments.length) throw new TypeError("1 argument required but 0 present"); + this.#subject.throw(value); } subscribe(observer: Observer): void { From 7c8441354a99c271945d75500c02e2bb49a732c3 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 12 Apr 2026 11:43:39 -0600 Subject: [PATCH 2/5] Updated docs and better argument validation to BroadcastSubject --- broadcast-subject/README.md | 15 ++++------ broadcast-subject/deno.json | 2 +- broadcast-subject/mod.test.ts | 37 +++++++++++++++++++++++ broadcast-subject/mod.ts | 55 ++++++++++++++++++++++++++++++----- 4 files changed, 91 insertions(+), 18 deletions(-) diff --git a/broadcast-subject/README.md b/broadcast-subject/README.md index 6cafda6..ac06de2 100644 --- a/broadcast-subject/README.md +++ b/broadcast-subject/README.md @@ -1,14 +1,11 @@ # [@observable/broadcast-subject](https://jsr.io/@observable/broadcast-subject) -A variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose -[`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values are -[`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and sent -only to [consumers](https://jsr.io/@observable/core#consumer) of _other_ -[`BroadcastSubject`](https://jsr.io/@observable/broadcast-subject/doc/~/BroadcastSubject) instances -with the same name even if they are in different browsing contexts (e.g. browser tabs). Logically, -[consumers](https://jsr.io/@observable/core#consumer) of the -[`BroadcastSubject`](https://jsr.io/@observable/broadcast-subject/doc/~/BroadcastSubject) do not -receive its _own_ [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values. +A variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose values are +[`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and +[pushed](https://jsr.io/@observable/core#push) only to +[consumers](https://jsr.io/@observable/core#consumer) of _other_ +[`BroadcastSubject`](https://jsr.io/@observable/broadcast-subject/doc/~/BroadcastSubject)s with the +same `name` even if they are in different browsing contexts (e.g. browser tabs). ## Build diff --git a/broadcast-subject/deno.json b/broadcast-subject/deno.json index ef79f64..975c790 100644 --- a/broadcast-subject/deno.json +++ b/broadcast-subject/deno.json @@ -1,6 +1,6 @@ { "name": "@observable/broadcast-subject", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "exports": "./mod.ts", "publish": { "exclude": ["*.test.ts"] } diff --git a/broadcast-subject/mod.test.ts b/broadcast-subject/mod.test.ts index 471cd98..70f495a 100644 --- a/broadcast-subject/mod.test.ts +++ b/broadcast-subject/mod.test.ts @@ -168,6 +168,30 @@ Deno.test( }, ); +Deno.test( + "BroadcastSubject.next should allow empty next when created with void type", + () => { + // Arrange + const subject = new BroadcastSubject("test"); + const postMessageCalls: Array> = []; + Object.defineProperty(BroadcastChannel.prototype, "postMessage", { + value: new Proxy(BroadcastChannel.prototype.postMessage, { + apply: (target, thisArg, argumentsList: [message: unknown]) => { + postMessageCalls.push(argumentsList); + return target.apply(thisArg, argumentsList); + }, + }), + }); + + // Act + subject.next(); + + // Assert + assertEquals(postMessageCalls, [[undefined]]); + subject.return(); + }, +); + Deno.test("BroadcastSubject.next should not abort signal", () => { // Arrange const subject = new BroadcastSubject("test"); @@ -262,6 +286,19 @@ Deno.test( }, ); +Deno.test("BroadcastSubject.throw should throw if called with no arguments", () => { + // Arrange + const subject = new BroadcastSubject("test"); + subject.return(); // Prevent memory leaks + + // Act / Assert + assertThrows( + () => subject.throw(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + Deno.test("BroadcastSubject.throw should pass through this subject", () => { // Arrange const error = new Error("test error"); diff --git a/broadcast-subject/mod.ts b/broadcast-subject/mod.ts index a15cb81..57852bf 100644 --- a/broadcast-subject/mod.ts +++ b/broadcast-subject/mod.ts @@ -10,11 +10,10 @@ export type BroadcastSubject = Subject; */ export interface BroadcastSubjectConstructor { /** - * Creates and returns an object that acts as a variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed - * values are [`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and sent only to [consumers](https://jsr.io/@observable/core#consumer) - * of _other_ {@linkcode BroadcastSubject} instances with the same {@linkcode name} even if they are in different browsing contexts (e.g. browser tabs). Logically, - * [consumers](https://jsr.io/@observable/core#consumer) of the {@linkcode BroadcastSubject} do not receive its _own_ - * [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values. + * Creates and returns an object that acts as a variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose values + * are [`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and [pushed](https://jsr.io/@observable/core#push) + * only to [consumers](https://jsr.io/@observable/core#consumer) of _other_ {@linkcode BroadcastSubject}s with the same + * {@linkcode name} even if they are in different browsing contexts (e.g. browser tabs). * @example * ```ts * import { BroadcastSubject } from "@observable/broadcast-subject"; @@ -46,6 +45,41 @@ export interface BroadcastSubjectConstructor { * ``` */ new (name: string): BroadcastSubject; + /** + * Creates and returns an object that acts as a variant of [`Subject`](https://jsr.io/@xan/subject/doc/~/Subject) whose {@linkcode Value|values} + * are [`structured cloned`](https://developer.mozilla.org/en-US/docs/Web/API/structuredClone) and [pushed](https://jsr.io/@observable/core#push) + * only to [consumers](https://jsr.io/@observable/core#consumer) of _other_ {@linkcode BroadcastSubject}s with the same + * {@linkcode name} even if they are in different browsing contexts (e.g. browser tabs). + * @example + * ```ts + * import { BroadcastSubject } from "@observable/broadcast-subject"; + * + * // Setup subjects + * const name = "test"; + * const controller = new AbortController(); + * const subject1 = new BroadcastSubject(name); + * const subject2 = new BroadcastSubject(name); + * + * // Subscribe to subjects + * subject1.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("subject1 received", value, "from subject1"), + * return: () => console.log("subject1 returned"), + * throw: (value) => console.log("subject1 threw", value), + * }); + * subject2.subscribe({ + * signal: controller.signal, + * next: (value) => console.log("subject2 received", value, "from subject2"), + * return: () => console.log("subject2 returned"), + * throw: (value) => console.log("subject2 threw", value), + * }); + * + * subject1.next(1); // subject2 received 1 from subject1 + * subject2.next(2); // subject1 received 2 from subject2 + * subject2.return(); // subject2 returned + * subject1.next(3); // No console output since subject2 is already returned + * ``` + */ new (name: string): BroadcastSubject; readonly prototype: BroadcastSubject; } @@ -83,10 +117,12 @@ export const BroadcastSubject: BroadcastSubjectConstructor = class { if (!(this instanceof BroadcastSubject)) { throw new TypeError(`'this' is not instanceof '${stringTag}'`); } + // No arguments.length check because Value may be void, making next() with no args valid. + try { this.#channel.postMessage(value); } catch (error) { - this.#subject.throw(error); + this.throw(error); } } @@ -96,8 +132,11 @@ export const BroadcastSubject: BroadcastSubjectConstructor = class { } throw(value: unknown): void { - if (this instanceof BroadcastSubject) this.#subject.throw(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!(this instanceof BroadcastSubject)) { + throw new TypeError(`'this' is not instanceof '${stringTag}'`); + } + if (!arguments.length) throw new TypeError("1 argument required but 0 present"); + this.#subject.throw(value); } subscribe(observer: Observer): void { From 7dc27b3c04d11acb8634615bf138c523ac51b835 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 12 Apr 2026 11:44:00 -0600 Subject: [PATCH 3/5] Updated docs and better argument validation to Subject --- core/deno.json | 2 +- core/observer.ts | 12 +++--------- core/subject.test.ts | 9 +++++++++ core/subject.ts | 25 +++++++++++++------------ 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/core/deno.json b/core/deno.json index 0caae28..bf490e1 100644 --- a/core/deno.json +++ b/core/deno.json @@ -1,6 +1,6 @@ { "name": "@observable/core", - "version": "0.11.0", + "version": "0.12.0", "license": "MIT", "exports": "./mod.ts", "publish": { "exclude": ["*.test.ts"] } diff --git a/core/observer.ts b/core/observer.ts index ee2c090..0e09a41 100644 --- a/core/observer.ts +++ b/core/observer.ts @@ -95,9 +95,7 @@ export const Observer: ObserverConstructor = class { } next(value: Value): void { - if (!(this instanceof Observer)) { - throw new TypeError(`'this' is not instanceof '${stringTag}'`); - } + if (!(this instanceof Observer)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); // No arguments.length check because Value may be void, making next() with no args valid. // If this observer has been aborted there is nothing to do. @@ -112,9 +110,7 @@ export const Observer: ObserverConstructor = class { } return(): void { - if (!(this instanceof Observer)) { - throw new TypeError(`'this' is not instanceof '${stringTag}'`); - } + if (!(this instanceof Observer)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); // If this observer has been aborted there is nothing to do. if (this.signal.aborted) return; @@ -135,9 +131,7 @@ export const Observer: ObserverConstructor = class { } throw(value: unknown): void { - if (!(this instanceof Observer)) { - throw new TypeError(`'this' is not instanceof '${stringTag}'`); - } + if (!(this instanceof Observer)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); if (!arguments.length) throw new TypeError("1 argument required but 0 present"); // If this observer has been aborted there is nothing to do. diff --git a/core/subject.test.ts b/core/subject.test.ts index 320b39e..d6a7d55 100644 --- a/core/subject.test.ts +++ b/core/subject.test.ts @@ -701,6 +701,15 @@ Deno.test("Subject should handle reentrant observers when returning", () => { ]); }); +Deno.test("Subject.throw should throw if called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => new Subject().throw(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + Deno.test("Subject should handle reentrant observers when throwing", () => { // Arrange const error = new Error("test"); diff --git a/core/subject.ts b/core/subject.ts index 73ce2a9..a4094e9 100644 --- a/core/subject.ts +++ b/core/subject.ts @@ -106,7 +106,7 @@ const notThrown = Symbol("Flag indicating that a value is not thrown."); */ const stringTag = "Subject"; -export const Subject: SubjectConstructor = class { +export const Subject: SubjectConstructor = class { readonly [Symbol.toStringTag] = stringTag; /** * Tracking the value that was thrown by the [producer](https://jsr.io/@observable/core#producer), if any. @@ -117,10 +117,10 @@ export const Subject: SubjectConstructor = class { * Tracking a known list of unique {@linkcode Observer|observers}, so we don't have to clone them while * iterating to prevent reentrant behaviors. */ - #observersSnapshot?: ReadonlySet; - readonly #observers = new Set(); + #observersSnapshot?: ReadonlySet>; + readonly #observers = new Set>(); - readonly #observer = new Observer({ + readonly #observer = new Observer({ next: (value) => { // Multicast this notification. (this.#observersSnapshot ??= new Set(this.#observers)).forEach( @@ -179,9 +179,11 @@ export const Subject: SubjectConstructor = class { Object.freeze(this); } - next(value: unknown): void { - if (this instanceof Subject) this.#observer.next(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + next(value: Value): void { + if (!(this instanceof Subject)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); + // No arguments.length check because Value may be void, making next() with no args valid. + + this.#observer.next(value); } return(): void { @@ -190,14 +192,13 @@ export const Subject: SubjectConstructor = class { } throw(value: unknown): void { - if (this instanceof Subject) this.#observer.throw(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!(this instanceof Subject)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!arguments.length) throw new TypeError("1 argument required but 0 present"); + this.#observer.throw(value); } subscribe(observer: Observer): void { - if (!(this instanceof Subject)) { - throw new TypeError(`'this' is not instanceof '${stringTag}'`); - } + if (!(this instanceof Subject)) throw new TypeError(`'this' is not instanceof '${stringTag}'`); if (!arguments.length) throw new TypeError("1 argument required but 0 present"); if (!isObserver(observer)) throw new TypeError("Parameter 1 is not of type 'Observer'"); this.#observable.subscribe(observer); From dfa84a33423896d0216ba0b9b7096b06783baa4b Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 12 Apr 2026 11:44:22 -0600 Subject: [PATCH 4/5] Updated docs and better argument validation to ReplaySubject --- replay-subject/README.md | 4 ++-- replay-subject/deno.json | 2 +- replay-subject/mod.test.ts | 27 +++++++++++++++++++++++++++ replay-subject/mod.ts | 13 +++++++++---- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/replay-subject/README.md b/replay-subject/README.md index 467afe2..c2a5d5b 100644 --- a/replay-subject/README.md +++ b/replay-subject/README.md @@ -1,8 +1,8 @@ # [@observable/replay-subject](https://jsr.io/@observable/replay-subject) A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that replays the last -integer `count` of buffered [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values -upon [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). +integer `count` of buffered values upon +[`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). ## Build diff --git a/replay-subject/deno.json b/replay-subject/deno.json index 72b3423..6875b99 100644 --- a/replay-subject/deno.json +++ b/replay-subject/deno.json @@ -1,6 +1,6 @@ { "name": "@observable/replay-subject", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "exports": "./mod.ts", "publish": { "exclude": ["*.test.ts"] } diff --git a/replay-subject/mod.test.ts b/replay-subject/mod.test.ts index 721e412..3871603 100644 --- a/replay-subject/mod.test.ts +++ b/replay-subject/mod.test.ts @@ -165,6 +165,24 @@ Deno.test("ReplaySubject.next should emit values to observers", () => { ]); }); +Deno.test( + "ReplaySubject.next should allow empty next when created with void type", + () => { + // Arrange + const subject = new ReplaySubject(1); + const notifications: Array> = []; + pipe(subject, materialize()).subscribe( + new Observer((notification) => notifications.push(notification)), + ); + + // Act + subject.next(); + + // Assert + assertEquals(notifications, [["next", undefined]]); + }, +); + Deno.test("ReplaySubject.next should store values for late observers", () => { // Arrange const subject = new ReplaySubject(2); @@ -184,6 +202,15 @@ Deno.test("ReplaySubject.next should store values for late observers", () => { ]); }); +Deno.test("ReplaySubject.throw should throw if called with no arguments", () => { + // Arrange / Act / Assert + assertThrows( + () => new ReplaySubject(1).throw(...([] as unknown as Parameters)), + TypeError, + "1 argument required but 0 present", + ); +}); + Deno.test("ReplaySubject.throw should pass through this subject", () => { // Arrange const error = new Error("test error"); diff --git a/replay-subject/mod.ts b/replay-subject/mod.ts index e660b23..1331277 100644 --- a/replay-subject/mod.ts +++ b/replay-subject/mod.ts @@ -15,7 +15,7 @@ export type ReplaySubject = Subject; export interface ReplaySubjectConstructor { /** * Creates and returns an object that acts as a variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that replays - * the last integer {@linkcode count} of buffered [`next`](https://jsr.io/@observable/core/doc/~/Observer.next)ed values upon + * the last integer {@linkcode count} of buffered {@linkcode Value|values} to [consumers](https://jsr.io/@observable/core#consumer) upon * [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). * @example * Positive integer count @@ -389,11 +389,14 @@ export const ReplaySubject: ReplaySubjectConstructor = class { if (!(this instanceof ReplaySubject)) { throw new TypeError(`'this' is not instanceof '${stringTag}'`); } + // No arguments.length check because Value may be void, making next() with no args valid. + if (!this.signal.aborted && this.#count > 0) { const length = this.#buffer.push(value); if (length > this.#count) this.#buffer.shift(); this.#bufferSnapshot = undefined; } + this.#subject.next(value); } @@ -403,10 +406,12 @@ export const ReplaySubject: ReplaySubjectConstructor = class { } throw(value: unknown): void { - if (this instanceof ReplaySubject) this.#subject.throw(value); - else throw new TypeError(`'this' is not instanceof '${stringTag}'`); + if (!(this instanceof ReplaySubject)) { + throw new TypeError(`'this' is not instanceof '${stringTag}'`); + } + if (!arguments.length) throw new TypeError("1 argument required but 0 present"); + this.#subject.throw(value); } - subscribe(observer: Observer): void { if (!(this instanceof ReplaySubject)) { throw new TypeError(`'this' is not instanceof '${stringTag}'`); From 6c66c46de8d279d651259b769b4b62577e9c5035 Mon Sep 17 00:00:00 2001 From: Alexander Harding Date: Sun, 12 Apr 2026 11:46:15 -0600 Subject: [PATCH 5/5] Small improvement in BehaviorSubject README.md --- behavior-subject/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/behavior-subject/README.md b/behavior-subject/README.md index c74168a..b134e68 100644 --- a/behavior-subject/README.md +++ b/behavior-subject/README.md @@ -1,7 +1,7 @@ # [@observable/behavior-subject](https://jsr.io/@observable/behavior-subject) A variant of [`Subject`](https://jsr.io/@observable/core/doc/~/Subject) that keeps track of its -current value and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon +current `value` and replays it to [`consumers`](https://jsr.io/@observable/core#consumer) upon [`subscribe`](https://jsr.io/@observable/core/doc/~/Observable.subscribe). ## Build