From efdf58d8f900088af82ec205ba8d4dd525bdac6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 25 Mar 2026 04:54:52 +0100 Subject: [PATCH] Add Lens --- bun.lock | 7 +- packages/effect-fc/package.json | 5 +- packages/effect-fc/src/Lens.test.ts | 139 --------- packages/effect-fc/src/Lens.ts | 442 ++-------------------------- 4 files changed, 38 insertions(+), 555 deletions(-) delete mode 100644 packages/effect-fc/src/Lens.test.ts diff --git a/bun.lock b/bun.lock index d82995b..86fc1a5 100644 --- a/bun.lock +++ b/bun.lock @@ -36,12 +36,15 @@ "packages/effect-fc": { "name": "effect-fc", "version": "0.2.4", + "dependencies": { + "effect-lens": "^0.1.0", + }, "devDependencies": { "@effect/platform-browser": "^0.74.0", }, "peerDependencies": { "@types/react": "^19.2.0", - "effect": "^3.19.0", + "effect": "^3.21.0", "react": "^19.2.0", }, }, @@ -1385,6 +1388,8 @@ "effect-fc": ["effect-fc@workspace:packages/effect-fc"], + "effect-lens": ["effect-lens@0.1.0", "", { "peerDependencies": { "effect": "^3.21.0" } }, "sha512-vD4yMMqFCPrMYAu9L3Q3HEwd1SPCpLHANEBf4f9A8fUVLkLUxt1XzNtdkOV7dJXA0vqgJuKdeBAJw2nfDZg1sA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index 0dee8af..6649875 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -42,7 +42,10 @@ }, "peerDependencies": { "@types/react": "^19.2.0", - "effect": "^3.19.0", + "effect": "^3.21.0", "react": "^19.2.0" + }, + "dependencies": { + "effect-lens": "^0.1.0" } } diff --git a/packages/effect-fc/src/Lens.test.ts b/packages/effect-fc/src/Lens.test.ts deleted file mode 100644 index 208d74a..0000000 --- a/packages/effect-fc/src/Lens.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Chunk, Effect, SubscriptionRef } from "effect" -import * as Lens from "./Lens.js" - - -describe("Lens", () => { - test("focusField focuses a nested property without touching other fields", async () => { - const [initialCount, updatedState] = await Effect.runPromise( - Effect.flatMap( - SubscriptionRef.make({ count: 1, label: "original" }), - parent => { - const countLens = Lens.focusField(Lens.fromSubscriptionRef(parent), "count") - return Effect.flatMap( - Lens.get(countLens), - count => Effect.flatMap( - Lens.set(countLens, count + 5), - () => Effect.map(parent.get, state => [count, state] as const), - ), - ) - }, - ), - ) - - expect(initialCount).toBe(1) - expect(updatedState).toEqual({ count: 6, label: "original" }) - }) - - test("focusMutableField preserves the root identity when mutating in place", async () => { - const original = { detail: "keep" } - const updated = await Effect.runPromise( - Effect.flatMap( - SubscriptionRef.make(original), - parent => { - const detailLens = Lens.focusMutableField(Lens.fromSubscriptionRef(parent), "detail") - return Effect.flatMap( - Lens.set(detailLens, "mutated"), - () => parent.get, - ) - }, - ), - ) - - expect(updated).toBe(original) - expect(updated.detail).toBe("mutated") - }) - - test("focusArrayAt updates the selected index", async () => { - const updated = await Effect.runPromise( - Effect.flatMap( - SubscriptionRef.make([10, 20, 30]), - parent => { - const elementLens = Lens.focusArrayAt(Lens.fromSubscriptionRef(parent), 1) - return Effect.flatMap( - Lens.update(elementLens, value => value + 5), - () => parent.get, - ) - }, - ), - ) - - expect(updated).toEqual([10, 25, 30]) - }) - - test("focusMutableArrayAt mutates the array reference in place", async () => { - const original = ["foo", "bar"] - const updated = await Effect.runPromise( - Effect.flatMap( - SubscriptionRef.make(original), - parent => { - const elementLens = Lens.focusMutableArrayAt(Lens.fromSubscriptionRef(parent), 0) - return Effect.flatMap( - Lens.set(elementLens, "baz"), - () => parent.get, - ) - }, - ), - ) - - expect(updated).toBe(original) - expect(updated).toEqual(["baz", "bar"]) - }) - - test("focusChunkAt replaces the focused chunk element", async () => { - const updated = await Effect.runPromise( - Effect.flatMap( - SubscriptionRef.make(Chunk.make(1, 2, 3) as Chunk.Chunk), - parent => { - const elementLens = Lens.focusChunkAt(Lens.fromSubscriptionRef(parent), 2) - return Effect.flatMap( - Lens.set(elementLens, 99), - () => parent.get, - ) - }, - ), - ) - - expect(Chunk.toReadonlyArray(updated)).toEqual([1, 2, 99]) - }) - - // test("changes stream emits updates when lens mutates state", async () => { - // const events = await Effect.runPromise( - // Effect.flatMap( - // SubscriptionRef.make({ count: 0 }), - // parent => { - // const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count") - // return Effect.fork(Stream.runCollect(Stream.take(lens.changes, 2))).pipe( - // Effect.tap(Lens.set(lens, 1)), - // Effect.tap(Lens.set(lens, 1)), - // Effect.andThen(Fiber.join), - // Effect.map(Chunk.toReadonlyArray), - // ) - // }, - // ), - // ) - - // expect(events).toEqual([1, 2]) - // }) - - // test("mapped changes stream can derive transformed values", async () => { - // const derived = await Effect.runPromise( - // Effect.flatMap( - // SubscriptionRef.make({ count: 10 }), - // parent => { - // const lens = Lens.mapField(Lens.fromSubscriptionRef(parent), "count") - // const transformed = Stream.map(lens.changes, count => `count:${ count }`) - // return Effect.scoped(() => Effect.flatMap( - // Effect.forkScoped(Stream.runCollect(Stream.take(transformed, 1))), - // fiber => Effect.flatMap( - // Lens.set(lens, 42), - // () => Effect.join(fiber), - // ), - // )) - // }, - // ), - // ) - - // expect(derived).toEqual(["count:42"]) - // }) -}) diff --git a/packages/effect-fc/src/Lens.ts b/packages/effect-fc/src/Lens.ts index a3daf7e..9b0bcee 100644 --- a/packages/effect-fc/src/Lens.ts +++ b/packages/effect-fc/src/Lens.ts @@ -1,424 +1,38 @@ -import { Array, Chunk, Effect, Function, Pipeable, Predicate, Readable, Stream, Subscribable, type SubscriptionRef, type SynchronizedRef } from "effect" -import type { NoSuchElementException } from "effect/Cause" +import { Effect, Equivalence, Stream } from "effect" +import { Lens } from "effect-lens" +import * as React from "react" +import * as Component from "./Component.js" +import * as SetStateAction from "./SetStateAction.js" -export const LensTypeId: unique symbol = Symbol.for("@effect-fc/Lens/Lens") -export type LensTypeId = typeof LensTypeId - -/** - * A bidirectional view into some shared state that exposes: - * - * 1. a `get` effect for reading the current value of type `A`, - * 2. a `changes` stream that emits every subsequent update to `A`, and - * 3. a `modify` effect that can transform the current value. - */ -export interface Lens -extends Subscribable.Subscribable { - readonly [LensTypeId]: LensTypeId - - readonly modify: ( - f: (a: A) => Effect.Effect - ) => Effect.Effect -} - -/** - * Internal `Lens` implementation. - */ -export class LensImpl - extends Pipeable.Class() implements Lens { - readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId - readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId - readonly [LensTypeId]: LensTypeId = LensTypeId - - constructor( - readonly get: Effect.Effect, - readonly changes: Stream.Stream, - readonly modify: ( - f: (a: A) => Effect.Effect - ) => Effect.Effect, - ) { - super() +export declare namespace useState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence } } +export const useState = Effect.fnUntraced(function* ( + lens: Lens.Lens, + options?: useState.Options>, +): Effect.fn.Return>], ER | EW, RR | RW> { + const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => Lens.get(lens))) -/** - * Checks whether a value is a `Lens`. - */ -export const isLens = (u: unknown): u is Lens => Predicate.hasProperty(u, LensTypeId) - - -/** - * Creates a `Lens` by supplying how to read the current value, observe changes, and apply transformations. - * - * Either `modify` or `set` needs to be supplied. - */ -export const make = ( - options: { - readonly get: Effect.Effect - readonly changes: Stream.Stream - } & ( - | { - readonly modify: ( - f: (a: A) => Effect.Effect - ) => Effect.Effect - } - | { readonly set: (a: A) => Effect.Effect } - ) -): Lens => new LensImpl( - options.get, - options.changes, - Predicate.hasProperty(options, "modify") - ? options.modify - : ( - f: (a: A) => Effect.Effect - ) => Effect.flatMap( - options.get, - a => Effect.flatMap(f(a), ([b, next]) => Effect.as(options.set(next), b) - )), -) - -/** - * Creates a `Lens` that proxies a `SubscriptionRef`. - */ -export const fromSubscriptionRef = ( - ref: SubscriptionRef.SubscriptionRef -): Lens => make({ - get get() { return ref.get }, - get changes() { return ref.changes }, - modify: ( - f: (a: A) => Effect.Effect - ) => ref.modifyEffect(f), -}) - -/** - * Creates a `Lens` that proxies a `SynchronizedRef`. - * - * Note: since `SynchronizedRef` does not provide any kind of reactivity mechanism, the produced `Lens` will be non-reactive. - * This means its `changes` stream will only emit the current value once when evaluated and nothing else. - */ -export const fromSynchronizedRef = ( - ref: SynchronizedRef.SynchronizedRef -): Lens => make({ - get get() { return ref.get }, - get changes() { return Stream.unwrap(Effect.map(ref.get, Stream.make)) }, - modify: ( - f: (a: A) => Effect.Effect - ) => ref.modifyEffect(f), -}) - -/** - * Flattens an effectful `Lens`. - */ -export const unwrap = ( - effect: Effect.Effect, E1, R1> -): Lens => make({ - get: Effect.flatMap(effect, l => l.get), - changes: Stream.unwrap(Effect.map(effect, l => l.changes)), - modify: ( - f: (a: A) => Effect.Effect - ) => Effect.flatMap(effect, l => l.modify(f)), -}) - - -/** - * Derives a new `Lens` by applying synchronous getters and setters over the focused value. - */ -export const map: { - ( - self: Lens, - get: (a: NoInfer) => B, - set: (a: NoInfer, b: B) => NoInfer, - ): Lens - ( - get: (a: NoInfer) => B, - set: (a: NoInfer, b: B) => NoInfer, - ): (self: Lens) => Lens -} = Function.dual(3, ( - self: Lens, - get: (a: NoInfer) => B, - set: (a: NoInfer, b: B) => NoInfer, -): Lens => make({ - get get() { return Effect.map(self.get, get) }, - get changes() { return Stream.map(self.changes, get) }, - modify: ( - f: (b: B) => Effect.Effect - ) => self.modify(a => - Effect.flatMap(f(get(a)), ([c, next]) => Effect.succeed([c, set(a, next)])) - ), -})) - -/** - * Derives a new `Lens` by applying effectful getters and setters over the focused value. - */ -export const mapEffect: { - ( - self: Lens, - get: (a: NoInfer) => Effect.Effect, - set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, - ): Lens - ( - get: (a: NoInfer) => Effect.Effect, - set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, - ): (self: Lens) => Lens -} = Function.dual(3, ( - self: Lens, - get: (a: NoInfer) => Effect.Effect, - set: (a: NoInfer, b: B) => Effect.Effect, ESet, RSet>, -): Lens => make({ - get get() { return Effect.flatMap(self.get, get) }, - get changes() { return Stream.mapEffect(self.changes, get) }, - modify: ( - f: (b: B) => Effect.Effect - ) => self.modify(a => Effect.flatMap( - get(a), - b => Effect.flatMap( - f(b), - ([c, bNext]) => Effect.flatMap( - set(a, bNext), - nextA => Effect.succeed([c, nextA] as const), - ), + yield* Component.useReactEffect(() => Effect.forkScoped( + Stream.runForEach( + Stream.changesWith(lens.changes, options?.equivalence ?? Equivalence.strict()), + v => Effect.sync(() => setReactStateValue(v)), ) - )), -})) + ), [lens]) -/** - * Allows transforming only the `changes` stream of a `Lens` while keeping the focus type intact. - */ -export const mapStream: { - ( - self: Lens, - f: (changes: Stream.Stream, NoInfer, NoInfer>) => Stream.Stream, NoInfer, NoInfer>, - ): Lens - ( - f: (changes: Stream.Stream, NoInfer, NoInfer>) => Stream.Stream, NoInfer, NoInfer>, - ): (self: Lens) => Lens -} = Function.dual(2, ( - self: Lens, - f: (changes: Stream.Stream, NoInfer, NoInfer>) => Stream.Stream, NoInfer, NoInfer>, -): Lens => make({ - get get() { return self.get }, - get changes() { return f(self.changes) }, - get modify() { return self.modify }, -})) + const setValue = yield* Component.useCallbackSync( + (setStateAction: React.SetStateAction) => Effect.andThen( + Lens.updateAndGet(lens, prevState => SetStateAction.value(setStateAction, prevState)), + v => setReactStateValue(v), + ), + [lens], + ) + return [reactStateValue, setValue] +}) -/** - * Narrows the focus to a field of an object. Replaces the object in an immutable fashion when written to. - */ -export const focusField: { - ( - self: Lens, - key: K, - ): Lens - ( - key: K, - ): (self: Lens) => Lens -} = Function.dual(2, ( - self: Lens, - key: K, -): Lens => map( - self, - a => a[key], - (a, b) => Object.setPrototypeOf({ ...a, [key]: b }, Object.getPrototypeOf(a)), -)) - -export declare namespace focusMutableField { - export type WritableKeys = { - [K in keyof T]-?: IfEquals< - { [P in K]: T[K] }, - { -readonly [P in K]: T[K] }, - K, - never - > - }[keyof T] - - type IfEquals = (() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B -} - -/** - * Narrows the focus to a mutable field of an object. Mutates the object in place when written to. - */ -export const focusMutableField: { - , ER, EW, RR, RW>( - self: Lens, - key: K, - ): Lens - , ER, EW, RR, RW>( - key: K, - ): (self: Lens) => Lens -} = Function.dual(2, , ER, EW, RR, RW>( - self: Lens, - key: K, -): Lens => map(self, a => a[key], (a, b) => { a[key] = b; return a })) - -/** - * Narrows the focus to an indexed element of an array. Replaces the array in an immutable fashion when written to. - */ -export const focusArrayAt: { - ( - self: Lens, - index: number, - ): Lens - ( - index: number - ): (self: Lens) => Lens -} = Function.dual(2, ( - self: Lens, - index: number, -): Lens => mapEffect( - self, - Array.get(index), - (a, b) => Array.replaceOption(a, index, b) as any, -)) - -/** - * Narrows the focus to an indexed element of a mutable array. Mutates the array in place when written to. - */ -export const focusMutableArrayAt: { - ( - self: Lens, - index: number, - ): Lens - ( - index: number - ): (self: Lens) => Lens -} = Function.dual(2, ( - self: Lens, - index: number, -): Lens => mapEffect( - self, - Array.get(index), - (a, b) => Effect.flatMap( - Array.get(a, index), - () => Effect.as(Effect.sync(() => { a[index] = b }), a), - ), -)) - -/** - * Narrows the focus to an indexed element of `Chunk`. Replaces the `Chunk` in an immutable fashion when written to. - */ -export const focusChunkAt: { - ( - self: Lens, ER, EW, RR, RW>, - index: number, - ): Lens - ( - index: number - ): (self: Lens, ER, EW, RR, RW>) => Lens -} = Function.dual(2, ( - self: Lens, ER, EW, RR, RW>, - index: number, -): Lens => mapEffect( - self, - Chunk.get(index), - (a, b) => Effect.succeed(Chunk.replace(a, index, b))), -) - - -/** - * Reads the current value from a `Lens`. - */ -export const get = (self: Lens): Effect.Effect => self.get - -/** - * Sets the value of a `Lens`. - */ -export const set: { - (value: A): (self: Lens) => Effect.Effect - (self: Lens, value: A): Effect.Effect -} = Function.dual(2, (self: Lens, value: A) => - self.modify(() => Effect.succeed([void 0, value] as const)), -) - -/** - * Sets a `Lens` to a new value and returns the previous value. - */ -export const getAndSet: { - (value: A): (self: Lens) => Effect.Effect - (self: Lens, value: A): Effect.Effect -} = Function.dual(2, (self: Lens, value: A) => - self.modify(a => Effect.succeed([a, value] as const)), -) - -/** - * Applies a synchronous transformation to the value of a `Lens`, discarding the previous value. - */ -export const update: { - (f: (a: A) => A): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => A): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => A) => - self.modify(a => Effect.succeed([void 0, f(a)] as const)), -) - -/** - * Applies an effectful transformation to the value of a `Lens`, discarding the previous value. - */ -export const updateEffect: { - (f: (a: A) => Effect.Effect): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => Effect.Effect): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => Effect.Effect) => - self.modify(a => Effect.flatMap( - f(a), - next => Effect.succeed([void 0, next] as const), - )), -) - -/** - * Applies a synchronous transformation the value of a `Lens` while returning the previous value. - */ -export const getAndUpdate: { - (f: (a: A) => A): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => A): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => A) => - self.modify(a => Effect.succeed([a, f(a)] as const)), -) - -/** - * Applies an effectful transformation the value of a `Lens` while returning the previous value. - */ -export const getAndUpdateEffect: { - (f: (a: A) => Effect.Effect): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => Effect.Effect): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => Effect.Effect) => - self.modify(a => Effect.flatMap( - f(a), - next => Effect.succeed([a, next] as const) - )), -) - -/** - * Sets the value of a `Lens` and returns the new value. - */ -export const setAndGet: { - (value: A): (self: Lens) => Effect.Effect - (self: Lens, value: A): Effect.Effect -} = Function.dual(2, (self: Lens, value: A) => - self.modify(() => Effect.succeed([value, value] as const)), -) - -/** - * Applies a synchronous update the value of a `Lens` and returns the new value. - */ -export const updateAndGet: { - (f: (a: A) => A): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => A): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => A) => - self.modify(a => { - const next = f(a) - return Effect.succeed([next, next] as const) - }), -) - -/** - * Applies an effectful update to the value of a `Lens` and returns the new value. - */ -export const updateAndGetEffect: { - (f: (a: A) => Effect.Effect): (self: Lens) => Effect.Effect - (self: Lens, f: (a: A) => Effect.Effect): Effect.Effect -} = Function.dual(2, (self: Lens, f: (a: A) => Effect.Effect) => - self.modify(a => Effect.flatMap( - f(a), - next => Effect.succeed([next, next] as const), - )), -) +export * from "effect-lens/Lens"