From 6bf4e33c29f0bcd8b146f95ff36927ccf027019d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 20 Oct 2025 06:34:59 +0200 Subject: [PATCH] 0.1.5 (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Renovate Bot Co-authored-by: Julien Valverdé Reviewed-on: https://git.valverde.cloud/Thilawyn/effect-fc/pulls/15 --- .gitea/workflows/test-build.yaml | 2 +- bun.lock | 4 +- package.json | 2 +- packages/effect-fc/package.json | 2 +- packages/effect-fc/src/Component.ts | 2 +- packages/effect-fc/src/Form.ts | 103 ++++++------------ packages/effect-fc/src/Subscribable.ts | 35 +++--- .../src/lib/form/TextFieldFormInput.tsx | 6 +- packages/example/src/routes/form.tsx | 26 +++-- 9 files changed, 68 insertions(+), 114 deletions(-) diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index 3ff60d7..c394801 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -10,7 +10,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: Setup Node - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "22" - name: Clone repo diff --git a/bun.lock b/bun.lock index 5a089e1..6079b3a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "name": "@effect-fc/monorepo", "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.42.0", + "@effect/language-service": "^0.45.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", @@ -135,7 +135,7 @@ "@effect-fc/example": ["@effect-fc/example@workspace:packages/example"], - "@effect/language-service": ["@effect/language-service@0.42.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-a5naAdmFxrp6T6IsKNTbsoPJXgn2/WXcjzHHrvq7O/MCCWWiJepSVeJiD8rhb8YsWhiNXnvV5/MzOtljwWHY7w=="], + "@effect/language-service": ["@effect/language-service@0.45.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-SEZ9TaVCpRKYumTQJPApg3os9O94bN2lCYQLgZbyK/xD+NSfYPPJZQ+6T5LkpcNgW8BRk1ACI7S1W2/noxm7Qg=="], "@effect/platform": ["@effect/platform@0.92.1", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.18.1" } }, "sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg=="], diff --git a/package.json b/package.json index 6373aa1..76911e6 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.2.5", - "@effect/language-service": "^0.42.0", + "@effect/language-service": "^0.45.0", "@types/bun": "^1.2.23", "npm-check-updates": "^19.0.0", "npm-sort": "^0.0.4", diff --git a/packages/effect-fc/package.json b/packages/effect-fc/package.json index e5f9d33..d87a362 100644 --- a/packages/effect-fc/package.json +++ b/packages/effect-fc/package.json @@ -1,7 +1,7 @@ { "name": "effect-fc", "description": "Write React function components with Effect", - "version": "0.1.4", + "version": "0.1.5", "type": "module", "files": [ "./README.md", diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 297cd38..dd1524f 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -55,7 +55,7 @@ const ComponentProto = Object.freeze({ this: Component ) { const self = this - // biome-ignore lint/style/noNonNullAssertion: context initialization + // biome-ignore lint/style/noNonNullAssertion: React ref initialization const runtimeRef = React.useRef>>(null!) runtimeRef.current = yield* Effect.runtime>() diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index 26a1e6b..e829a11 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,10 +1,10 @@ import * as AsyncData from "@typed/async-data" -import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, pipe, Ref, Schema, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect" +import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream, SubscriptionRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import * as Hooks from "./Hooks/index.js" import * as PropertyPath from "./PropertyPath.js" -import * as SubscribableInternal from "./Subscribable.js" +import * as Subscribable from "./Subscribable.js" import * as SubscriptionSubRef from "./SubscriptionSubRef.js" @@ -16,7 +16,7 @@ extends Pipeable.Pipeable { readonly [FormTypeId]: FormTypeId readonly schema: Schema.Schema - readonly submit: (value: NoInfer) => Effect.Effect + readonly onSubmit: (value: NoInfer) => Effect.Effect readonly debounce: Option.Option readonly valueRef: SubscriptionRef.SubscriptionRef> @@ -34,7 +34,7 @@ extends Pipeable.Class() implements Form { constructor( readonly schema: Schema.Schema, - readonly submit: (value: NoInfer) => Effect.Effect, + readonly onSubmit: (value: NoInfer) => Effect.Effect, readonly debounce: Option.Option, readonly valueRef: SubscriptionRef.SubscriptionRef>, @@ -55,7 +55,7 @@ export namespace make { export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer - readonly submit: (value: NoInfer) => Effect.Effect, + readonly onSubmit: (value: NoInfer) => Effect.Effect, readonly debounce?: Duration.DurationInput, } } @@ -74,7 +74,7 @@ export const make: { return new FormImpl( options.schema, - options.submit, + options.onSubmit, Option.fromNullable(options.debounce), valueRef, @@ -83,28 +83,14 @@ export const make: { validationFiberRef, submitStateRef, - pipe( - ([value, error, validationFiber, submitState]: readonly [ - Option.Option, - Option.Option, - Option.Option>, - AsyncData.AsyncData, - ]) => Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && !AsyncData.isLoading(submitState), - - filter => SubscribableInternal.make({ - get: Effect.map(Effect.all([valueRef, errorRef, validationFiberRef, submitStateRef]), filter), - get changes() { - return Stream.map( - Stream.zipLatestAll( - valueRef.changes, - errorRef.changes, - validationFiberRef.changes, - submitStateRef.changes, - ), - filter, - ) - }, - }), + Subscribable.map( + Subscribable.zipLatestAll(valueRef, errorRef, validationFiberRef, submitStateRef), + ([value, error, validationFiber, submitState]) => ( + Option.isSome(value) && + Option.isNone(error) && + Option.isNone(validationFiber) && + !AsyncData.isLoading(submitState) + ), ), ) }) @@ -156,13 +142,14 @@ export const submit = ( Effect.andThen(identity), Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())), Effect.andThen(flow( - self.submit, + self.onSubmit as (value: NoInfer) => Effect.Effect, + Effect.tapErrorTag("ParseError", e => Ref.set(self.errorRef, Option.some(e as ParseResult.ParseError))), Effect.exit, Effect.map(Exit.match({ onSuccess: a => AsyncData.success(a), - onFailure: e => AsyncData.failure(e), + onFailure: e => AsyncData.failure(e as Cause.Cause), })), - Effect.tap(v => Ref.set(self.submitStateRef, v)) + Effect.tap(v => Ref.set(self.submitStateRef, v)), )), ), @@ -185,48 +172,20 @@ export const field = , path: P, ): FormField, PropertyPath.ValueFromPath> => new FormFieldImpl( - pipe( - Option.match({ - onSome: (v: A) => Option.map(PropertyPath.get(v, path), Option.some), - onNone: () => Option.some(Option.none()), - }), - filter => SubscribableInternal.make({ - get: Effect.flatMap(self.valueRef, filter), - get changes() { return Stream.flatMap(self.valueRef.changes, filter) }, - }), - ), - + Subscribable.mapEffect(self.valueRef, Option.match({ + onSome: v => Option.map(PropertyPath.get(v, path), Option.some), + onNone: () => Option.some(Option.none()), + })), SubscriptionSubRef.makeFromPath(self.encodedValueRef, path), - - pipe( - Option.match({ - onSome: (v: ParseResult.ParseError) => Effect.andThen( - ParseResult.ArrayFormatter.formatError(v), - Array.filter(issue => PropertyPath.equivalence(issue.path, path)), - ), - onNone: () => Effect.succeed([]), - }), - filter => SubscribableInternal.make({ - get: Effect.flatMap(self.errorRef.get, filter), - get changes() { return Stream.flatMap(self.errorRef.changes, filter) }, - }), - ), - - pipe( - Option.isSome, - filter => SubscribableInternal.make({ - get: Effect.map(self.validationFiberRef.get, filter), - get changes() { return Stream.map(self.validationFiberRef.changes, filter) }, - }), - ), - - pipe( - AsyncData.isLoading, - filter => SubscribableInternal.make({ - get: Effect.map(self.submitStateRef, filter), - get changes() { return Stream.map(self.submitStateRef.changes, filter) }, - }), - ), + Subscribable.mapEffect(self.errorRef, Option.match({ + onSome: flow( + ParseResult.ArrayFormatter.formatError, + Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))), + ), + onNone: () => Effect.succeed([]), + })), + Subscribable.map(self.validationFiberRef, Option.isSome), + Subscribable.map(self.submitStateRef, AsyncData.isLoading) ) diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 3325fdc..9ebba36 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -1,24 +1,17 @@ -import { type Effect, Effectable, Readable, type Stream, Subscribable } from "effect" +import { Effect, Stream, Subscribable } from "effect" -class SubscribableImpl -extends Effectable.Class implements Subscribable.Subscribable { - readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId - readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId +export const zipLatestAll = >>( + ...subscribables: T +): Subscribable.Subscribable< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _R : never +> => Subscribable.make({ + get: Effect.all(subscribables.map(v => v.get)), + changes: Stream.zipLatestAll(...subscribables.map(v => v.changes)), +}) as any - constructor( - readonly get: Effect.Effect, - readonly changes: Stream.Stream, - ) { - super() - } - - commit() { - return this.get - } -} - -export const make = (values: { - readonly get: Effect.Effect - readonly changes: Stream.Stream -}): Subscribable.Subscribable => new SubscribableImpl(values.get, values.changes) +export * from "effect/Subscribable" diff --git a/packages/example/src/lib/form/TextFieldFormInput.tsx b/packages/example/src/lib/form/TextFieldFormInput.tsx index 85db7fa..567afdb 100644 --- a/packages/example/src/lib/form/TextFieldFormInput.tsx +++ b/packages/example/src/lib/form/TextFieldFormInput.tsx @@ -1,5 +1,5 @@ import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes" -import { Array, Option } from "effect" +import { Array, Option, Struct } from "effect" import { Component, Form, Hooks } from "effect-fc" @@ -10,7 +10,7 @@ extends TextField.RootProps, Form.useInput.Options { } interface OptionalProps -extends Omit, Form.useOptionalInput.Options { +extends Omit, Form.useOptionalInput.Options { readonly optional: true readonly field: Form.FormField> } @@ -41,7 +41,7 @@ export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInp value={input.value} onChange={e => input.setValue(e.target.value)} disabled={(input.optional && !input.enabled) || isSubmitting} - {...props} + {...Struct.omit(props, "optional", "defaultValue")} > {input.optional && diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index c00ff11..722e2d8 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -39,7 +39,7 @@ class RegisterForm extends Effect.Service()("RegisterForm", { ), initialEncodedValue: { email: "", password: "", birth: Option.none() }, - submit: v => Effect.sleep("500 millis").pipe( + onSubmit: v => Effect.sleep("500 millis").pipe( Effect.andThen(Console.log(v)), Effect.andThen(Effect.sync(() => alert("Done!"))), ), @@ -47,7 +47,7 @@ class RegisterForm extends Effect.Service()("RegisterForm", { }) }) {} -class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() { +class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() { const form = yield* RegisterForm const submit = yield* Form.useSubmit(form) const [canSubmit] = yield* Hooks.useSubscribables(form.canSubmitSubscribable) @@ -84,16 +84,18 @@ class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() { ) }) {} +const RegisterPage = Component.makeUntraced("RegisterPage")(function*() { + const RegisterFormViewFC = yield* Effect.provide( + RegisterFormView, + yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }), + ) + + return +}).pipe( + Component.withRuntime(runtime.context) +) + export const Route = createFileRoute("/form")({ - component: Component.makeUntraced("RegisterRoute")(function*() { - const RegisterRouteFC = yield* Effect.provide( - RegisterPage, - yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }), - ) - - return - }).pipe( - Component.withRuntime(runtime.context) - ) + component: RegisterPage })