From e5237b2a9d455398ee7ba91720c295236bf8510f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 3 May 2026 21:31:42 +0200 Subject: [PATCH] Refactor SubmittableForm --- packages/effect-fc/src/SubmittableForm.ts | 164 +++++++++++----------- 1 file changed, 83 insertions(+), 81 deletions(-) diff --git a/packages/effect-fc/src/SubmittableForm.ts b/packages/effect-fc/src/SubmittableForm.ts index 87c4c3e..da8bb07 100644 --- a/packages/effect-fc/src/SubmittableForm.ts +++ b/packages/effect-fc/src/SubmittableForm.ts @@ -1,4 +1,4 @@ -import { Array, Cause, Chunk, type Context, Effect, Exit, Fiber, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect" +import { Array, Cause, Chunk, type Context, Effect, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, SubscriptionRef } from "effect" import * as Form from "./Form.js" import * as Lens from "./Lens.js" import * as Mutation from "./Mutation.js" @@ -21,7 +21,6 @@ extends Form.Form { > readonly validationFiber: Subscribable.Subscribable>, never, never> - readonly run: Effect.Effect readonly submit: Effect.Effect>, Cause.NoSuchElementException> } @@ -32,6 +31,11 @@ extends Pipeable.Class() implements SubmittableForm { readonly path = [] as const + readonly encodedValue: Lens.Lens + readonly isValidating: Subscribable.Subscribable + readonly canCommit: Subscribable.Subscribable + readonly isCommitting: Subscribable.Subscribable + constructor( readonly schema: Schema.Schema, readonly context: Context.Context, @@ -40,68 +44,99 @@ extends Pipeable.Class() implements SubmittableForm { MA, ME, MR, MP >, readonly value: Lens.Lens, never, never, never, never>, - readonly encodedValue: Lens.Lens, + readonly internalEncodedValue: Lens.Lens, readonly issues: Lens.Lens, readonly validationFiber: Lens.Lens>, never, never, never, never>, - readonly isValidating: Subscribable.Subscribable, - - readonly canCommit: Subscribable.Subscribable, - readonly isCommitting: Subscribable.Subscribable, readonly runSemaphore: Effect.Semaphore, ) { super() + + this.encodedValue = Effect.succeed(this).pipe( + Effect.map(self => Lens.make({ + get get() { return self.internalEncodedValue.get }, + get changes() { return self.internalEncodedValue.changes }, + modify: f => self.internalEncodedValue.modify( + encodedValue => Effect.map( + f(encodedValue), + ([b, nextEncodedValue]) => [ + [b, nextEncodedValue] as const, + nextEncodedValue, + ] as const + ) + ).pipe( + Effect.tap(([, nextEncodedValue]) => + self.synchronizeEncodedValue(nextEncodedValue).pipe( + Effect.forkScoped, + Effect.provide(self.context), + ) + ), + Effect.map(([b]) => b), + ), + })), + Lens.unwrap, + ) + this.isValidating = Effect.succeed(this).pipe( + Effect.map(self => Subscribable.map(self.validationFiber, Option.isSome)), + Subscribable.unwrap, + ) + this.canCommit = Effect.succeed(this).pipe( + Effect.map(self => Subscribable.map( + Subscribable.zipLatestAll(self.value, self.issues, self.validationFiber, self.mutation.result), + ([value, issues, validationFiber, result]) => ( + Option.isSome(value) && + Array.isEmptyReadonlyArray(issues) && + Option.isNone(validationFiber) && + !(Result.isRunning(result) || Result.hasRefreshingFlag(result)) + ), + )), + Subscribable.unwrap, + ) + this.isCommitting = Effect.succeed(this).pipe( + Effect.map(self => Subscribable.map( + self.mutation.result, + result => Result.isRunning(result) || Result.hasRefreshingFlag(result), + )), + Subscribable.unwrap, + ) } - get run(): Effect.Effect { - return this.runSemaphore.withPermits(1)(Effect.provide( - Stream.runForEach( - this.encodedValue.changes, + synchronizeEncodedValue(encodedValue: I): Effect.Effect { + return Lens.get(this.validationFiber).pipe( + Effect.andThen(Option.match({ + onSome: Fiber.interrupt, + onNone: () => Effect.void, + })), + Effect.andThen(Effect.forkScoped( + Effect.ensuring( + Schema.decode(this.schema, { errors: "all" })(encodedValue), + Lens.set(this.validationFiber, Option.none()), + ) + )), + Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))), + Effect.flatMap(Fiber.join), - encodedValue => Lens.get(this.validationFiber).pipe( - Effect.andThen(Option.match({ - onSome: Fiber.interrupt, - onNone: () => Effect.void, - })), - Effect.andThen( - Effect.forkScoped(Effect.onExit( - Schema.decode(this.schema, { errors: "all" })(encodedValue), - exit => Effect.andThen( - Exit.matchEffect(exit, { - onSuccess: v => Effect.andThen( - Lens.set(this.value, Option.some(v)), - Lens.set(this.issues, Array.empty()), - ), - onFailure: c => Option.match(Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"), { - onSome: e => Effect.flatMap( - ParseResult.ArrayFormatter.formatError(e), - v => Lens.set(this.issues, v), - ), - onNone: () => Effect.void, - }), - }), - Lens.set(this.validationFiber, Option.none()), - ), - )) - ), - Effect.tap(fiber => Lens.set(this.validationFiber, Option.some(fiber))), - Effect.andThen(Fiber.join), - Effect.ignore, + Effect.flatMap(value => Lens.set(this.value, Option.some(value))), + Effect.catchIf( + ParseResult.isParseError, + flow( + ParseResult.ArrayFormatter.formatError, + Effect.flatMap(v => Lens.set(this.issues, v)), ), ), - this.context, - )) + Effect.provide(this.context), + ) } - get submit(): Effect.Effect>, Cause.NoSuchElementException> { + get submit(): Effect.Effect>, Cause.NoSuchElementException, never> { return Lens.get(this.value).pipe( Effect.andThen(identity), Effect.andThen(value => this.submitValue(value)), ) } - submitValue(value: A): Effect.Effect>> { + submitValue(value: A): Effect.Effect>, never, never> { return Effect.whenEffect( Effect.tap( this.mutation.mutate([value, this as any]), @@ -148,49 +183,16 @@ export const make = Effect.fnUntraced(function* > { - const mutation = yield* Mutation.make(options) - const valueLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())) - const issuesLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())) - const validationFiberLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())) - return new SubmittableFormImpl( options.schema, yield* Effect.context(), - mutation, + yield* Mutation.make(options), - valueLens, + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none())), Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)), - issuesLens, - validationFiberLens, - Subscribable.map(validationFiberLens, Option.isSome), - - Subscribable.map( - Subscribable.zipLatestAll(valueLens, issuesLens, validationFiberLens, mutation.result), - ([value, issues, validationFiber, result]) => ( - Option.isSome(value) && - Array.isEmptyReadonlyArray(issues) && - Option.isNone(validationFiber) && - !(Result.isRunning(result) || Result.hasRefreshingFlag(result)) - ), - ), - Subscribable.map(mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Array.empty())), + Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none>())), yield* Effect.makeSemaphore(1), ) }) - -export declare namespace service { - export interface Options - extends make.Options {} -} - -export const service = ( - options: service.Options -): Effect.Effect< - SubmittableForm, MP>, - never, - Scope.Scope | R | Result.forkEffect.OutputContext -> => Effect.tap( - make(options), - form => Effect.forkScoped(form.run), -)