From 6c208986db24e97918f792e787cada6969ee86d0 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 22 Sep 2023 13:51:41 +1200 Subject: [PATCH] add RxRef module & hook --- .changeset/friendly-dogs-matter.md | 5 + docs/rx-react/index.ts.md | 11 ++ docs/rx/RxRef.ts.md | 115 +++++++++++++++++ packages/rx-react/src/index.ts | 12 ++ packages/rx/src/RxRef.ts | 192 +++++++++++++++++++++++++++++ packages/rx/test/RxRef.test.ts | 43 +++++++ 6 files changed, 378 insertions(+) create mode 100644 .changeset/friendly-dogs-matter.md create mode 100644 docs/rx/RxRef.ts.md create mode 100644 packages/rx/src/RxRef.ts create mode 100644 packages/rx/test/RxRef.test.ts diff --git a/.changeset/friendly-dogs-matter.md b/.changeset/friendly-dogs-matter.md new file mode 100644 index 0000000..a0a4f79 --- /dev/null +++ b/.changeset/friendly-dogs-matter.md @@ -0,0 +1,5 @@ +--- +"@effect-rx/rx-react": patch +--- + +add RxRef module & hook diff --git a/docs/rx-react/index.ts.md b/docs/rx-react/index.ts.md index 30399e0..badf5aa 100644 --- a/docs/rx-react/index.ts.md +++ b/docs/rx-react/index.ts.md @@ -17,6 +17,7 @@ Added in v1.0.0 - [hooks](#hooks) - [useRefreshRx](#userefreshrx) - [useRx](#userx) + - [useRxRef](#userxref) - [useRxSuspense](#userxsuspense) - [useRxSuspenseSuccess](#userxsuspensesuccess) - [useRxValue](#userxvalue) @@ -60,6 +61,16 @@ export declare const useRx: ( Added in v1.0.0 +## useRxRef + +**Signature** + +```ts +export declare const useRxRef: (ref: RxRef.ReadonlyRef) => A +``` + +Added in v1.0.0 + ## useRxSuspense **Signature** diff --git a/docs/rx/RxRef.ts.md b/docs/rx/RxRef.ts.md new file mode 100644 index 0000000..f18b31a --- /dev/null +++ b/docs/rx/RxRef.ts.md @@ -0,0 +1,115 @@ +--- +title: RxRef.ts +nav_order: 4 +parent: "@effect-rx/rx" +--- + +## RxRef overview + +Added in v1.0.0 + +--- + +

Table of contents

+ +- [constructors](#constructors) + - [collection](#collection) + - [make](#make) +- [models](#models) + - [Collection (interface)](#collection-interface) + - [ReadonlyRef (interface)](#readonlyref-interface) + - [RxRef (interface)](#rxref-interface) +- [type ids](#type-ids) + - [TypeId](#typeid) + - [TypeId (type alias)](#typeid-type-alias) + +--- + +# constructors + +## collection + +**Signature** + +```ts +export declare const collection:
(items: Iterable) => Collection +``` + +Added in v1.0.0 + +## make + +**Signature** + +```ts +export declare const make: (value: A) => RxRef +``` + +Added in v1.0.0 + +# models + +## Collection (interface) + +**Signature** + +```ts +export interface Collection extends ReadonlyRef>> { + readonly push: (item: A) => Collection + readonly insertAt: (index: number, item: A) => Collection + readonly remove: (ref: RxRef) => Collection +} +``` + +Added in v1.0.0 + +## ReadonlyRef (interface) + +**Signature** + +```ts +export interface ReadonlyRef { + readonly [TypeId]: TypeId + readonly key: string + readonly value: A + readonly subscribe: (f: (a: A) => void) => () => void + readonly map: (f: (a: A) => B) => ReadonlyRef +} +``` + +Added in v1.0.0 + +## RxRef (interface) + +**Signature** + +```ts +export interface RxRef extends ReadonlyRef { + readonly set: (value: A) => RxRef + readonly update: (f: (value: A) => A) => RxRef +} +``` + +Added in v1.0.0 + +# type ids + +## TypeId + +**Signature** + +```ts +export declare const TypeId: typeof TypeId +``` + +Added in v1.0.0 + +## TypeId (type alias) + +**Signature** + +```ts +export type TypeId = typeof TypeId +``` + +Added in v1.0.0 diff --git a/packages/rx-react/src/index.ts b/packages/rx-react/src/index.ts index 9ed1522..98f9cac 100644 --- a/packages/rx-react/src/index.ts +++ b/packages/rx-react/src/index.ts @@ -4,6 +4,7 @@ import * as Registry from "@effect-rx/rx/Registry" import * as Result from "@effect-rx/rx/Result" import * as Rx from "@effect-rx/rx/Rx" +import type * as RxRef from "@effect-rx/rx/RxRef" import { globalValue } from "@effect/data/GlobalValue" import * as Cause from "@effect/io/Cause" import * as React from "react" @@ -11,6 +12,7 @@ import * as React from "react" export * as Registry from "@effect-rx/rx/Registry" export * as Result from "@effect-rx/rx/Result" export * as Rx from "@effect-rx/rx/Rx" +export * as RxRef from "@effect-rx/rx/RxRef" /** * @since 1.0.0 @@ -194,3 +196,13 @@ export const useRxSuspenseSuccess = ( value: result.value.value } } + +/** + * @since 1.0.0 + * @category hooks + */ +export const useRxRef = (ref: RxRef.ReadonlyRef): A => { + const [value, setValue] = React.useState(ref.value) + React.useEffect(() => ref.subscribe(setValue), [ref]) + return value +} diff --git a/packages/rx/src/RxRef.ts b/packages/rx/src/RxRef.ts new file mode 100644 index 0000000..40655f1 --- /dev/null +++ b/packages/rx/src/RxRef.ts @@ -0,0 +1,192 @@ +/** + * @since 1.0.0 + */ +import * as Equal from "@effect/data/Equal" +import { globalValue } from "@effect/data/GlobalValue" + +/** + * @since 1.0.0 + * @category type ids + */ +export const TypeId = Symbol.for("@effect-rx/rx/RxRef") + +/** + * @since 1.0.0 + * @category type ids + */ +export type TypeId = typeof TypeId + +/** + * @since 1.0.0 + * @category models + */ +export interface ReadonlyRef { + readonly [TypeId]: TypeId + readonly key: string + readonly value: A + readonly subscribe: (f: (a: A) => void) => () => void + readonly map: (f: (a: A) => B) => ReadonlyRef +} + +/** + * @since 1.0.0 + * @category models + */ +export interface RxRef extends ReadonlyRef { + readonly set: (value: A) => RxRef + readonly update: (f: (value: A) => A) => RxRef +} + +/** + * @since 1.0.0 + * @category models + */ +export interface Collection extends ReadonlyRef>> { + readonly push: (item: A) => Collection + readonly insertAt: (index: number, item: A) => Collection + readonly remove: (ref: RxRef) => Collection +} + +/** + * @since 1.0.0 + * @category constructors + */ +export const make = (value: A): RxRef => new RxRefImpl(value) + +/** + * @since 1.0.0 + * @category constructors + */ +export const collection = (items: Iterable): Collection => new CollectionImpl(items) + +class Subscribable { + listeners: Array<(a: A) => void> = [] + listenerCount = 0 + + notify(a: A) { + for (let i = 0; i < this.listenerCount; i++) { + this.listeners[i](a) + } + } + + subscribe(f: (a: A) => void): () => void { + this.listeners.push(f) + this.listenerCount++ + + return () => { + const index = this.listeners.indexOf(f) + if (index !== -1) { + this.listeners[index] = this.listeners[this.listenerCount - 1] + this.listeners.pop() + this.listenerCount-- + } + } + } +} + +const keyState = globalValue("@effect-rx/rx/RxRef/keyState", () => ({ + count: 0, + generate() { + return `RxRef-${this.count++}` + } +})) + +class ReadonlyRefImpl extends Subscribable implements ReadonlyRef { + readonly [TypeId]: TypeId + readonly key = keyState.generate() + constructor(public value: A) { + super() + this[TypeId] = TypeId + } + map(f: (a: A) => B): ReadonlyRef { + return new MapRefImpl(this, f) + } +} + +class RxRefImpl extends ReadonlyRefImpl implements RxRef { + set(value: A) { + if (Equal.equals(value, this.value)) { + return this + } + this.value = value + this.notify(value) + return this + } + + update(f: (value: A) => A) { + return this.set(f(this.value)) + } +} + +class MapRefImpl implements ReadonlyRef { + readonly [TypeId]: TypeId + readonly key = keyState.generate() + constructor(readonly parent: ReadonlyRef, readonly transform: (a: A) => B) { + this[TypeId] = TypeId + } + get value() { + return this.transform(this.parent.value) + } + subscribe(f: (a: B) => void): () => void { + let previous = this.transform(this.parent.value) + return this.parent.subscribe((a) => { + const next = this.transform(a) + if (Equal.equals(next, previous)) { + return + } + previous = next + f(next) + }) + } + map(f: (a: B) => C): ReadonlyRef { + return new MapRefImpl(this, f) + } +} + +class CollectionImpl extends ReadonlyRefImpl>> implements Collection { + constructor(items: Iterable) { + super([]) + for (const item of items) { + this.value.push(this.makeRef(item)) + } + } + + makeRef(value: A) { + const ref = new RxRefImpl(value) + const notify = (value: A) => { + ref.notify(value) + this.notify(this.value) + } + return new Proxy(ref, { + get(target, p, _receiver) { + if (p === "notify") { + return notify + } + return target[p as keyof RxRef] + } + }) + } + + push(item: A) { + const ref = this.makeRef(item) + this.value.push(ref) + this.notify(this.value) + return this + } + + insertAt(index: number, item: A) { + const ref = this.makeRef(item) + this.value.splice(index, 0, ref) + this.notify(this.value) + return this + } + + remove(ref: RxRef) { + const index = this.value.indexOf(ref) + if (index !== -1) { + this.value.splice(index, 1) + this.notify(this.value) + } + return this + } +} diff --git a/packages/rx/test/RxRef.test.ts b/packages/rx/test/RxRef.test.ts new file mode 100644 index 0000000..d38a353 --- /dev/null +++ b/packages/rx/test/RxRef.test.ts @@ -0,0 +1,43 @@ +import * as RxRef from "@effect-rx/rx/RxRef" + +describe("RxRef", () => { + describe("make", () => { + test("notifies", () => { + const ref = RxRef.make(0) + + const updates: Array = [] + const cancel = ref.subscribe((_) => updates.push(_)) + + ref.set(-1) + ref.set(-2) + ref.set(-3) + ref.set(-3) + + assert.deepEqual(updates, [-1, -2, -3]) + + cancel() + ref.set(0) + assert.deepEqual(updates, [-1, -2, -3]) + }) + }) + + describe("collection", () => { + test("listens to children", () => { + const coll = RxRef.collection([1, 2, 3]) + + let count = 0 + const cancel = coll.subscribe(() => count++) + + coll.value[0].set(-1) + coll.value[1].set(-2) + coll.value[2].set(-3) + coll.value[2].set(-3) + + expect(count).toBe(3) + + cancel() + coll.value[0].set(0) + expect(count).toBe(3) + }) + }) +})