From cbdcee039a7cf248ef4e7a90a4ff9764efd94d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 25 Mar 2026 12:41:26 +0100 Subject: [PATCH] Refactor Form to use Lens --- packages/effect-fc/src/Form.ts | 118 ++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 52 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index a44da20..be86f0c 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,12 +1,12 @@ import { Array, Cause, Chunk, type Context, type Duration, Effect, Equal, Exit, Fiber, flow, Hash, HashMap, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type * as React from "react" import * as Component from "./Component.js" +import * as Lens from "./Lens.js" import * as Mutation from "./Mutation.js" import * as PropertyPath from "./PropertyPath.js" import * as Result from "./Result.js" import * as Subscribable from "./Subscribable.js" import * as SubscriptionRef from "./SubscriptionRef.js" -import * as SubscriptionSubRef from "./SubscriptionSubRef.js" export const FormTypeId: unique symbol = Symbol.for("@effect-fc/Form/Form") @@ -26,7 +26,7 @@ extends Pipeable.Pipeable { readonly debounce: Option.Option readonly value: Subscribable.Subscribable> - readonly encodedValue: SubscriptionRef.SubscriptionRef + readonly encodedValue: Lens.Lens readonly error: Subscribable.Subscribable> readonly validationFiber: Subscribable.Subscribable>> @@ -54,10 +54,10 @@ extends Pipeable.Class() implements Form { readonly autosubmit: boolean, readonly debounce: Option.Option, - readonly value: SubscriptionRef.SubscriptionRef>, - readonly encodedValue: SubscriptionRef.SubscriptionRef, - readonly error: SubscriptionRef.SubscriptionRef>, - readonly validationFiber: SubscriptionRef.SubscriptionRef>>, + readonly value: Lens.Lens>, + readonly encodedValue: Lens.Lens, + readonly error: Lens.Lens>, + readonly validationFiber: Lens.Lens>>, readonly runSemaphore: Effect.Semaphore, readonly fieldCache: Ref.Ref>>, @@ -99,7 +99,7 @@ extends Pipeable.Class() implements Form { Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity ), - encodedValue => this.validationFiber.pipe( + encodedValue => Lens.get(this.validationFiber).pipe( Effect.andThen(Option.match({ onSome: Fiber.interrupt, onNone: () => Effect.void, @@ -110,18 +110,18 @@ extends Pipeable.Class() implements Form { exit => Effect.andThen( Exit.matchEffect(exit, { onSuccess: v => Effect.andThen( - Ref.set(this.value, Option.some(v)), - Ref.set(this.error, Option.none()), + Lens.set(this.value, Option.some(v)), + Lens.set(this.error, Option.none()), ), onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { - onSome: e => Ref.set(this.error, Option.some(e)), + onSome: e => Lens.set(this.error, Option.some(e)), onNone: () => Effect.void, }), }), - Ref.set(this.validationFiber, Option.none()), + Lens.set(this.validationFiber, Option.none()), ), )).pipe( - Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))), + Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))), Effect.andThen(Fiber.join), Effect.andThen(value => this.autosubmit ? Effect.asVoid(Effect.forkScoped(this.submitValue(value))) @@ -136,7 +136,7 @@ extends Pipeable.Class() implements Form { } get submit(): Effect.Effect>, Cause.NoSuchElementException> { - return this.value.pipe( + return Lens.get(this.value).pipe( Effect.andThen(identity), Effect.andThen(value => this.submitValue(value)), ) @@ -153,7 +153,7 @@ extends Pipeable.Class() implements Form { e => e._tag === "ParseError", ), { - onSome: e => Ref.set(this.error, Option.some(e)), + onSome: e => Lens.set(this.error, Option.some(e)), onNone: () => Effect.void, }, ) @@ -193,10 +193,10 @@ export const make = Effect.fnUntraced(function* ()), - yield* SubscriptionRef.make(options.initialEncodedValue), - yield* SubscriptionRef.make(Option.none()), - yield* SubscriptionRef.make(Option.none>()), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), yield* Effect.makeSemaphore(1), yield* Ref.make(HashMap.empty>()), @@ -228,7 +228,7 @@ extends Pipeable.Pipeable { readonly [FormFieldTypeId]: FormFieldTypeId readonly value: Subscribable.Subscribable, Cause.NoSuchElementException> - readonly encodedValue: SubscriptionRef.SubscriptionRef + readonly encodedValue: Lens.Lens readonly issues: Subscribable.Subscribable readonly isValidating: Subscribable.Subscribable readonly isSubmitting: Subscribable.Subscribable @@ -240,7 +240,7 @@ extends Pipeable.Class() implements FormField { constructor( readonly value: Subscribable.Subscribable, Cause.NoSuchElementException>, - readonly encodedValue: SubscriptionRef.SubscriptionRef, + readonly encodedValue: Lens.Lens, readonly issues: Subscribable.Subscribable, readonly isValidating: Subscribable.Subscribable, readonly isSubmitting: Subscribable.Subscribable, @@ -276,7 +276,7 @@ export const makeFormField = Option.map(PropertyPath.get(v, path), Option.some), onNone: () => Option.some(Option.none()), })), - SubscriptionSubRef.makeFromPath(self.encodedValue, path), + Lens.map(self.encodedValue, a => Option.getOrThrow(PropertyPath.get(a, path)), (a, b) => Option.getOrThrow(PropertyPath.immutableSet(a, path, b))), Subscribable.mapEffect(self.error, Option.match({ onSome: flow( ParseResult.ArrayFormatter.formatError, @@ -305,29 +305,35 @@ export const useInput = Effect.fnUntraced(function* ( field: FormField, options?: useInput.Options, ): Effect.fn.Return, Cause.NoSuchElementException, Scope.Scope> { - const internalValueRef = yield* Component.useOnChange(() => Effect.tap( - Effect.andThen(field.encodedValue, SubscriptionRef.make), - internalValueRef => Effect.forkScoped(Effect.all([ + const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() { + const internalValueLens = yield* Lens.get(field.encodedValue).pipe( + Effect.flatMap(SubscriptionRef.make), + Effect.map(Lens.fromSubscriptionRef), + ) + + yield* Effect.forkScoped(Effect.all([ Stream.runForEach( - Stream.drop(field.encodedValue, 1), + Stream.drop(field.encodedValue.changes, 1), upstreamEncodedValue => Effect.whenEffect( - Ref.set(internalValueRef, upstreamEncodedValue), - Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), + Lens.set(internalValueLens, upstreamEncodedValue), + Effect.andThen(Lens.get(internalValueLens), internalValue => !Equal.equals(upstreamEncodedValue, internalValue)), ), ), Stream.runForEach( - internalValueRef.changes.pipe( + internalValueLens.changes.pipe( Stream.drop(1), Stream.changesWith(Equal.equivalence()), options?.debounce ? Stream.debounce(options.debounce) : identity, ), - internalValue => Ref.set(field.encodedValue, internalValue), + internalValue => Lens.set(field.encodedValue, internalValue), ), - ], { concurrency: "unbounded" })), - ), [field, options?.debounce]) + ], { concurrency: "unbounded" })) - const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) + return internalValueLens + }), [field, options?.debounce]) + + const [value, setValue] = yield* Lens.useState(internalValueLens) return { value, setValue } }) @@ -346,51 +352,59 @@ export const useOptionalInput = Effect.fnUntraced(function* ( field: FormField>, options: useOptionalInput.Options, ): Effect.fn.Return, Cause.NoSuchElementException, Scope.Scope> { - const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( - Effect.andThen( - field.encodedValue, + const [enabledLens, internalValueLens] = yield* Component.useOnChange(() => Effect.gen(function*() { + const [enabledLens, internalValueLens] = yield* Effect.flatMap( + Lens.get(field.encodedValue), Option.match({ - onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]), - onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]), + onSome: v => Effect.all([ + Effect.map(SubscriptionRef.make(true), Lens.fromSubscriptionRef), + Effect.map(SubscriptionRef.make(v), Lens.fromSubscriptionRef), + ]), + onNone: () => Effect.all([ + Effect.map(SubscriptionRef.make(false), Lens.fromSubscriptionRef), + Effect.map(SubscriptionRef.make(options.defaultValue), Lens.fromSubscriptionRef), + ]), }), - ), + ) - ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ + yield* Effect.forkScoped(Effect.all([ Stream.runForEach( - Stream.drop(field.encodedValue, 1), + Stream.drop(field.encodedValue.changes, 1), upstreamEncodedValue => Effect.whenEffect( Option.match(upstreamEncodedValue, { onSome: v => Effect.andThen( - Ref.set(enabledRef, true), - Ref.set(internalValueRef, v), + Lens.set(enabledLens, true), + Lens.set(internalValueLens, v), ), onNone: () => Effect.andThen( - Ref.set(enabledRef, false), - Ref.set(internalValueRef, options.defaultValue), + Lens.set(enabledLens, false), + Lens.set(internalValueLens, options.defaultValue), ), }), Effect.andThen( - Effect.all([enabledRef, internalValueRef]), + Effect.all([Lens.get(enabledLens), Lens.get(internalValueLens)]), ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ), ), ), Stream.runForEach( - enabledRef.changes.pipe( - Stream.zipLatest(internalValueRef.changes), + enabledLens.changes.pipe( + Stream.zipLatest(internalValueLens.changes), Stream.drop(1), Stream.changesWith(Equal.equivalence()), options?.debounce ? Stream.debounce(options.debounce) : identity, ), - ([enabled, internalValue]) => Ref.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), + ([enabled, internalValue]) => Lens.set(field.encodedValue, enabled ? Option.some(internalValue) : Option.none()), ), - ], { concurrency: "unbounded" })), - ), [field, options.debounce]) + ], { concurrency: "unbounded" })) - const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) - const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) + return [enabledLens, internalValueLens] as const + }), [field, options.debounce]) + + const [enabled, setEnabled] = yield* Lens.useState(enabledLens) + const [value, setValue] = yield* Lens.useState(internalValueLens) return { enabled, setEnabled, value, setValue } })