0.1.4 #5

Merged
Thilawyn merged 67 commits from next into master 2025-10-02 18:18:23 +02:00
2 changed files with 91 additions and 122 deletions
Showing only changes of commit 7545b4bb30 - Show all commits

View File

@@ -1,4 +1,4 @@
import { Array, Effect, Equivalence, flow, Option, ParseResult, Pipeable, Schema, Stream, type Subscribable, SubscriptionRef } from "effect" import { Array, Duration, Effect, Equivalence, flow, identity, Option, ParseResult, Pipeable, Schema, Stream, Subscribable, SubscriptionRef } from "effect"
import type { NoSuchElementException } from "effect/Cause" import type { NoSuchElementException } from "effect/Cause"
import * as React from "react" import * as React from "react"
import { Hooks } from "./hooks/index.js" import { Hooks } from "./hooks/index.js"
@@ -10,147 +10,116 @@ export type TypeId = typeof TypeId
export interface Form<in out A, in out I = A, out R = never> export interface Form<in out A, in out I = A, out R = never>
extends Pipeable.Pipeable { extends Pipeable.Pipeable {
readonly schema: Schema.Schema<A, I, R> readonly schema: Schema.Schema<A, I, R>,
readonly latestValueSubscribable: Subscribable.Subscribable<A> readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
useFieldLatestValueSubscribable<P extends PropertyPath.Paths<A>>( makeFieldIssuesSubscribable<P extends PropertyPath.Paths<A>>(path: P): Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
path: P
): Subscribable.Subscribable<PropertyPath.ValueFromPath<A, P>, NoSuchElementException>
useFieldIssuesSubscribable(
path: PropertyPath.Paths<A>
): Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
useInput<P extends PropertyPath.Paths<I>>(
options: Form.useInput.Options<I, P>
): Effect.Effect<
Form.useInput.Result<PropertyPath.ValueFromPath<I, P>>,
ParseResult.ParseError | NoSuchElementException,
R
>
}
export namespace Form {
export namespace useInput {
export interface Options<I, P extends PropertyPath.Paths<I>> {
readonly path: P
readonly defaultValue?: PropertyPath.ValueFromPath<NoInfer<I>, NoInfer<P>>
}
export interface Result<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
readonly issues: readonly ParseResult.ArrayFormatterIssue[]
}
}
} }
class FormImpl<in out A, in out I, out R> class FormImpl<in out A, in out I, out R>
extends Pipeable.Class() implements Form<A, I, R> { extends Pipeable.Class() implements Form<A, I, R> {
readonly [TypeId]: TypeId = TypeId readonly [TypeId]: TypeId = TypeId
readonly latestValueSubscribable: Subscribable.Subscribable<A>
constructor( constructor(
readonly schema: Schema.Schema<A, I, R>, readonly schema: Schema.Schema<A, I, R>,
readonly latestValueRef: SubscriptionRef.SubscriptionRef<A>, readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly latestCandidateRef: SubscriptionRef.SubscriptionRef<I>, readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>, readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
) { ) {
super() super()
this.latestValueSubscribable = latestValueRef
} }
useFieldLatestValueSubscribable<P extends PropertyPath.Paths<A>>( makeFieldIssuesSubscribable<P extends PropertyPath.Paths<A>>(path: P) {
path: P const filter = Option.match({
) { onSome: (v: ParseResult.ParseError) => Effect.andThen(
return React.useMemo(() => { ParseResult.ArrayFormatter.formatError(v),
const latestValueRef = this.latestValueRef Array.filter(issue => PropertyPath.equivalence(issue.path, path)),
return SubscribableInternal.make({ ),
get: Effect.flatMap(latestValueRef.get, PropertyPath.get(path)), onNone: () => Effect.succeed([]),
get changes() { return Stream.flatMap(latestValueRef.changes, PropertyPath.get(path)) }, })
})
}, [this.latestValueRef, ...path])
}
useFieldIssuesSubscribable( const errorRef = this.errorRef
path: PropertyPath.Paths<A> return SubscribableInternal.make({
) { get: Effect.flatMap(errorRef.get, filter),
return React.useMemo(() => { get changes() { return Stream.flatMap(errorRef.changes, filter) },
const filter = Option.match({
onSome: (v: ParseResult.ParseError) => Effect.andThen(
ParseResult.ArrayFormatter.formatError(v),
Array.filter(issue => PropertyPath.equivalence(issue.path, path)),
),
onNone: () => Effect.succeed([]),
})
const errorRef = this.errorRef
return SubscribableInternal.make({
get: Effect.flatMap(errorRef.get, filter),
get changes() { return Stream.flatMap(errorRef.changes, filter) },
})
}, [this.latestValueRef, ...path])
}
useInput<P extends PropertyPath.Paths<I>>(
options: Form.useInput.Options<I, P>
) {
const self = this
return Effect.gen(function*() {
const internalValueRef = yield* Hooks.useMemo(() => self.latestValueRef.pipe(
Effect.andThen(flow(
Schema.encode(self.schema),
Effect.andThen(PropertyPath.get(options.path)),
Effect.catchTag("ParseError", e => options.defaultValue !== undefined && options.defaultValue !== null
? Effect.succeed(options.defaultValue)
: Effect.fail(e)
),
)),
Effect.andThen(SubscriptionRef.make<PropertyPath.ValueFromPath<I, P>>),
), [self.latestValueRef, ...options.path])
const issuesSubscribable = self.useFieldIssuesSubscribable(options.path)
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
const [issues] = yield* Hooks.useSubscribe(issuesSubscribable)
yield* Hooks.useFork(() => Stream.runForEach(
internalValueRef.changes.pipe(
Stream.changesWith(Equivalence.strict()),
// options.debounce ? Stream.debounce(options.debounce) : identity,
Stream.drop(1),
),
internalValue => self.latestValueRef.pipe(
Effect.andThen(Schema.encode(self.schema)),
Effect.andThen(PropertyPath.immutableSet(options.path, internalValue)),
Effect.andThen(flow(
Schema.decode(self.schema),
Effect.andThen(v => SubscriptionRef.set(self.latestValueRef, v)),
Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())),
Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e)))
)),
),
), [internalValueRef, self.latestValueRef, self.schema, self.errorRef, ...options.path])
return { value, setValue, issues }
}) })
} }
} }
export const make: {
<A, I = A, R = never>(options: make.Options<A, I, R>): Effect.Effect<Form<A, I, R>>
} = Effect.fnUntraced(function* <A, I = A, R = never>(options: make.Options<A, I, R>) {
return new FormImpl(
options.schema,
yield* SubscriptionRef.make(options.initialValue),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
)
})
export namespace make { export namespace make {
export interface Options<in out A, in out I = A, out R = never> { export interface Options<in out A, in out I = A, out R = never> {
readonly schema: Schema.Schema<A, I, R> readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I> readonly initialEncodedValue: NoInfer<I>
} }
} }
export const make: {
<A, I = A, R = never>(options: make.Options<A, I, R>): Effect.Effect<Form<A, I, R>>
} = Effect.fnUntraced(function* <A, I = A, R = never>(options: make.Options<A, I, R>) {
return new FormImpl(
options.schema,
yield* SubscriptionRef.make(Option.none<A>()),
yield* SubscriptionRef.make(options.initialEncodedValue),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
)
})
export namespace useInput {
export interface Options<I, P extends PropertyPath.Paths<I>> {
readonly path: P
readonly debounce?: Duration.DurationInput
}
export interface Result<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
readonly issues: readonly ParseResult.ArrayFormatterIssue[]
}
}
export const useInput: {
<A, I, R, P extends PropertyPath.Paths<I>>(
self: Form<A, I, R>,
options: useInput.Options<NoInfer<I>, P>,
): Effect.Effect<useInput.Result<PropertyPath.ValueFromPath<I, P>>, NoSuchElementException, R>
} = Effect.fnUntraced(function* <A, I, R, P extends PropertyPath.Paths<I>>(
self: Form<A, I, R>,
options: useInput.Options<NoInfer<I>, P>,
) {
const [internalValueRef, issuesSubscribable] = yield* Hooks.useMemo(() => Effect.all([
self.encodedValueRef.pipe(
Effect.andThen(PropertyPath.get(options.path)),
Effect.andThen(SubscriptionRef.make<PropertyPath.ValueFromPath<I, P>>),
),
Effect.succeed(self.makeFieldIssuesSubscribable(options.path)),
]), [self, ...options.path])
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
const [issues] = yield* Hooks.useSubscribe(issuesSubscribable)
yield* Hooks.useFork(() => Stream.runForEach(
internalValueRef.changes.pipe(
Stream.changesWith(Equivalence.strict()),
options.debounce ? Stream.debounce(options.debounce) : identity,
Stream.drop(1),
),
internalValue => self.encodedValueRef.pipe(
Effect.andThen(encodedValue => PropertyPath.immutableSet(encodedValue, options.path, internalValue)),
Effect.tap(encodedValue => SubscriptionRef.set(self.encodedValueRef, encodedValue)),
Effect.andThen(flow(
Schema.decode(self.schema),
Effect.andThen(v => SubscriptionRef.set(self.valueRef, Option.some(v))),
Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())),
Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e)))
)),
),
), [internalValueRef, self, ...options.path])
return { value, setValue, issues }
})

View File

@@ -14,16 +14,16 @@ const RegisterFormSchema = Schema.Struct({
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", { class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
scoped: Form.make({ scoped: Form.make({
schema: RegisterFormSchema, schema: RegisterFormSchema,
initialValue: { email: "", password: "" }, initialEncodedValue: { email: "", password: "" },
}) })
}) {} }) {}
class RegisterPage extends Component.makeUntraced(function* RegisterPage() { class RegisterPage extends Component.makeUntraced(function* RegisterPage() {
const form = yield* RegisterForm const form = yield* RegisterForm
const emailInput = yield* form.useInput({ path: ["email"], defaultValue: "" }) const emailInput = yield* Form.useInput(form, { path: ["email"] })
const passwordInput = yield* form.useInput({ path: ["password"], defaultValue: "" }) const passwordInput = yield* Form.useInput(form, { path: ["password"] })
yield* useFork(() => Stream.runForEach(form.latestValueSubscribable.changes, Console.log), []) yield* useFork(() => Stream.runForEach(form.valueRef.changes, Console.log), [])
return ( return (
<Container> <Container>