From 12878cd76b4ad4cd98700f6b56755c394df52e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 27 Oct 2025 16:47:58 +0100 Subject: [PATCH] Result work --- packages/effect-fc/src/Form.ts | 20 +++--- packages/effect-fc/src/Result.ts | 76 +++++++++++++++++++---- packages/effect-fc/src/SubscriptionRef.ts | 31 +++++++-- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index b78d296..f523e4a 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,9 +1,9 @@ -import * as AsyncData from "@typed/async-data" import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import * as Component from "./Component.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" @@ -25,7 +25,7 @@ extends Pipeable.Pipeable { readonly encodedValueRef: SubscriptionRef.SubscriptionRef readonly errorRef: SubscriptionRef.SubscriptionRef> readonly validationFiberRef: SubscriptionRef.SubscriptionRef>> - readonly submitStateRef: SubscriptionRef.SubscriptionRef> + readonly submitResultRef: SubscriptionRef.SubscriptionRef> readonly canSubmitSubscribable: Subscribable.Subscribable } @@ -44,7 +44,7 @@ extends Pipeable.Class() implements Form { readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, readonly validationFiberRef: SubscriptionRef.SubscriptionRef>>, - readonly submitStateRef: SubscriptionRef.SubscriptionRef>, + readonly submitResultRef: SubscriptionRef.SubscriptionRef>, readonly canSubmitSubscribable: Subscribable.Subscribable, ) { @@ -77,7 +77,7 @@ export const make: { const valueRef = yield* SubscriptionRef.make(Option.none()) const errorRef = yield* SubscriptionRef.make(Option.none()) const validationFiberRef = yield* SubscriptionRef.make(Option.none>()) - const submitStateRef = yield* SubscriptionRef.make(AsyncData.noData()) + const submitResultRef = yield* SubscriptionRef.make>(Result.initial()) return new FormImpl( options.schema, @@ -89,15 +89,15 @@ export const make: { yield* SubscriptionRef.make(options.initialEncodedValue), errorRef, validationFiberRef, - submitStateRef, + submitResultRef, Subscribable.map( - Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitStateRef), - ([value, error, validationFiber, submitState]) => ( + Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitResultRef), + ([value, error, validationFiber, submitResult]) => ( Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && - !AsyncData.isLoading(submitState) + (Result.isRunning(submitResult) || Result.isRefreshing(submitResult)) ), ), ) @@ -198,11 +198,11 @@ export const field = Effect.succeed([]), })), Subscribable.map(self.validationFiberRef, Option.isSome), - Subscribable.map(self.submitStateRef, AsyncData.isLoading) + Subscribable.map(self.submitResultRef, flow(Result.isRunning, Result.isRefreshing)), ) -export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField") +export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField") export type FormFieldTypeId = typeof FormFieldTypeId export interface FormField diff --git a/packages/effect-fc/src/Result.ts b/packages/effect-fc/src/Result.ts index 1f8036d..04784c3 100644 --- a/packages/effect-fc/src/Result.ts +++ b/packages/effect-fc/src/Result.ts @@ -1,8 +1,8 @@ -import { Cause, Exit, Option, Pipeable, Predicate } from "effect" +import { Cause, Effect, Equal, Exit, Hash, Match, Option, Pipeable, Predicate, pipe, Queue, type Scope } from "effect" -export const TypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") -export type TypeId = typeof TypeId +export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result") +export type ResultTypeId = typeof ResultTypeId export type Result = ( | Initial @@ -14,8 +14,8 @@ export type Result = ( ) export namespace Result { - export interface Prototype extends Pipeable.Pipeable { - readonly [TypeId]: TypeId + export interface Prototype extends Pipeable.Pipeable, Equal.Equal { + readonly [ResultTypeId]: ResultTypeId } export type Success> = [R] extends [Result] ? A : never @@ -51,20 +51,61 @@ export interface Refreshing

{ const ResultPrototype = Object.freeze({ ...Pipeable.Prototype, - [TypeId]: TypeId, + [ResultTypeId]: ResultTypeId, + + [Equal.symbol](this: Result, that: Result): boolean { + if (this._tag !== that._tag) + return false + + return Match.value(this).pipe( + Match.tag("Initial", () => true), + Match.tag("Running", self => Equal.equals(self.progress, (that as Running).progress)), + Match.tag("Success", self => + Equal.equals(self.value, (that as Success).value) && + (isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) && + Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined) + ), + Match.tag("Failure", self => + Equal.equals(self.cause, (that as Failure).cause) && + (isRefreshing(self) ? self.refreshing : false) === (isRefreshing(that) ? that.refreshing : false) && + Equal.equals(isRefreshing(self) ? self.progress : undefined, isRefreshing(that) ? that.progress : undefined) + ), + Match.exhaustive, + ) + }, + + [Hash.symbol](this: Result): number { + const tagHash = Hash.string(this._tag) + + return Match.value(this).pipe( + Match.tag("Initial", () => tagHash), + Match.tag("Running", self => Hash.combine(Hash.hash(self.progress))(tagHash)), + Match.tag("Success", self => pipe(tagHash, + Hash.combine(Hash.hash(self.value)), + Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)), + )), + Match.tag("Failure", self => pipe(tagHash, + Hash.combine(Hash.hash(self.cause)), + Hash.combine(Hash.hash(isRefreshing(self) ? self.progress : undefined)), + )), + Match.exhaustive, + Hash.cached(this), + ) + }, } as const satisfies Result.Prototype) -export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, TypeId) +export const isResult = (u: unknown): u is Result => Predicate.hasProperty(u, ResultTypeId) export const isInitial = (u: unknown): u is Initial => isResult(u) && u._tag === "Initial" +export const isRunning = (u: unknown): u is Running => isResult(u) && u._tag === "Running" export const isSuccess = (u: unknown): u is Success => isResult(u) && u._tag === "Success" export const isFailure = (u: unknown): u is Failure => isResult(u) && u._tag === "Failure" export const isRefreshing = (u: unknown): u is Refreshing => isResult(u) && Predicate.hasProperty(u, "refreshing") && u.refreshing export const initial = (): Initial => Object.setPrototypeOf({}, ResultPrototype) export const running =

(progress?: P): Running

=> Object.setPrototypeOf({ progress }, ResultPrototype) -export const success = (value: A): Success => Object.setPrototypeOf({ value }, ResultPrototype) -export const failure = ( +export const succeed = (value: A): Success => Object.setPrototypeOf({ value }, ResultPrototype) +export const fail = ( cause: Cause.Cause, previousSuccess?: Success, ): Failure => Object.setPrototypeOf({ @@ -79,11 +120,12 @@ export const refreshing = | Failure, P = never Object.getPrototypeOf(result), ) + export const fromExit = ( exit: Exit.Exit ): Success | Failure => exit._tag === "Success" - ? success(exit.value) - : failure(exit.cause) + ? succeed(exit.value) + : fail(exit.cause) export const toExit = ( self: Result @@ -97,3 +139,15 @@ export const toExit = ( return Exit.fail(new Cause.NoSuchElementException()) } } + +export const forkEffect = ( + effect: Effect.Effect +): Effect.Effect>, never, Scope.Scope | R> => Queue.unbounded>().pipe( + Effect.tap(Queue.offer(initial())), + Effect.tap(queue => Effect.forkScoped(Queue.offer(queue, running()).pipe( + Effect.andThen(effect), + Effect.exit, + Effect.andThen(exit => Queue.offer(queue, fromExit(exit))), + Effect.andThen(Queue.shutdown(queue)), + ))), +) diff --git a/packages/effect-fc/src/SubscriptionRef.ts b/packages/effect-fc/src/SubscriptionRef.ts index eb7e9b8..f8ead11 100644 --- a/packages/effect-fc/src/SubscriptionRef.ts +++ b/packages/effect-fc/src/SubscriptionRef.ts @@ -4,16 +4,26 @@ import * as Component from "./Component.js" import * as SetStateAction from "./SetStateAction.js" +export namespace useSubscriptionRefState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + export const useSubscriptionRefState: { ( - ref: SubscriptionRef.SubscriptionRef + ref: SubscriptionRef.SubscriptionRef, + options?: useSubscriptionRefState.Options>, ): Effect.Effect>], never, Scope.Scope> -} = Effect.fnUntraced(function* (ref: SubscriptionRef.SubscriptionRef) { +} = Effect.fnUntraced(function* ( + ref: SubscriptionRef.SubscriptionRef, + options?: useSubscriptionRefState.Options>, +) { const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref)) yield* Component.useReactEffect(() => Effect.forkScoped( Stream.runForEach( - Stream.changesWith(ref.changes, Equivalence.strict()), + Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()), v => Effect.sync(() => setReactStateValue(v)), ) ), [ref]) @@ -28,14 +38,23 @@ export const useSubscriptionRefState: { return [reactStateValue, setValue] }) +export namespace useSubscriptionRefFromState { + export interface Options { + readonly equivalence?: Equivalence.Equivalence + } +} + export const useSubscriptionRefFromState: { - (state: readonly [A, React.Dispatch>]): Effect.Effect, never, Scope.Scope> -} = Effect.fnUntraced(function*([value, setValue]) { + ( + state: readonly [A, React.Dispatch>], + options?: useSubscriptionRefFromState.Options>, + ): Effect.Effect, never, Scope.Scope> +} = Effect.fnUntraced(function*([value, setValue], options) { const ref = yield* Component.useOnChange(() => Effect.tap( SubscriptionRef.make(value), ref => Effect.forkScoped( Stream.runForEach( - Stream.changesWith(ref.changes, Equivalence.strict()), + Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()), v => Effect.sync(() => setValue(v)), ) ),