2 Commits

Author SHA1 Message Date
e5d0808b02 0.2.6 (#49)
All checks were successful
Publish / publish (push) Successful in 30s
Lint / lint (push) Successful in 14s
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #49
2026-05-04 02:10:53 +02:00
ff13e941e3 0.2.5 (#43)
All checks were successful
Publish / publish (push) Successful in 52s
Lint / lint (push) Successful in 14s
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Co-authored-by: Renovate Bot <renovate-bot@valverde.cloud>
Reviewed-on: #43
2026-03-31 21:01:12 +02:00
39 changed files with 1424 additions and 1587 deletions

0
.codex Normal file
View File

649
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@effect-fc/monorepo",
"packageManager": "bun@1.3.6",
"packageManager": "bun@1.3.13",
"private": true,
"workspaces": [
"./packages/*"
@@ -15,12 +15,12 @@
"clean:modules": "turbo clean:modules && rm -rf node_modules"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@effect/language-service": "^0.80.0",
"@types/bun": "^1.3.6",
"npm-check-updates": "^19.3.1",
"@biomejs/biome": "^2.4.14",
"@effect/language-service": "^0.85.1",
"@types/bun": "^1.3.13",
"npm-check-updates": "^22.1.0",
"npm-sort": "^0.0.4",
"turbo": "^2.7.5",
"typescript": "^5.9.3"
"turbo": "^2.9.8",
"typescript": "^6.0.3"
}
}

View File

@@ -7,6 +7,4 @@ tags: [hola, docusaurus]
Lorem ipsum dolor sit amet...
<!-- truncate -->
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

View File

@@ -9,8 +9,6 @@ This is the summary of a very long blog post,
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
<!-- truncate -->
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

View File

@@ -9,8 +9,6 @@ tags: [facebook, hello, docusaurus]
Here are a few tips you might find useful.
<!-- truncate -->
Simply add Markdown files (or folders) to the `blog` directory.
Regular blog authors can be added to `authors.yml`.

View File

@@ -15,19 +15,20 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.9.2",
"@docusaurus/preset-classic": "3.9.2",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "^3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.5",
"react-dom": "^19.2.5"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.9.2",
"@docusaurus/tsconfig": "3.9.2",
"@docusaurus/types": "3.9.2",
"typescript": "~5.9.0"
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"typescript": "~6.0.3"
},
"browserslist": {
"production": [

View File

@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.2.4",
"version": "0.2.6",
"type": "module",
"files": [
"./README.md",
@@ -38,7 +38,7 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/platform-browser": "^0.74.0"
"@effect/platform-browser": "^0.76.0"
},
"peerDependencies": {
"@types/react": "^19.2.0",
@@ -46,6 +46,6 @@
"react": "^19.2.0"
},
"dependencies": {
"effect-lens": "^0.1.0"
"effect-lens": "^0.1.5"
}
}

View File

@@ -500,6 +500,31 @@ export const makeUntraced: (
)
)
export declare namespace withSignature {
export type Signature = (props: any) => React.ReactNode
export type Result<
T extends Component<any, any, any, any>,
F extends Signature,
> = Omit<T, "use" | "asFunctionComponent"> & {
readonly use: Effect.Effect<F, never, Exclude<Component.Context<T>, Scope.Scope>>
asFunctionComponent(
runtimeRef: React.Ref<Runtime.Runtime<Exclude<Component.Context<T>, Scope.Scope>>>
): F
}
}
export const withSignature: {
<F extends withSignature.Signature>(): <T extends Component<any, any, any, any>>(
self: T
) => withSignature.Result<T, F>
<F extends withSignature.Signature, T extends Component<any, any, any, any>>(
self: T
): withSignature.Result<T, F>
} = (self?: Component<any, any, any, any>): any => self === undefined
? identity
: self
/**
* Creates a new component with modified configuration options while preserving all original behavior.
*

View File

@@ -1,293 +1,157 @@
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 { Array, type Cause, Chunk, type Duration, Effect, Equal, Function, identity, Option, type ParseResult, Pipeable, Predicate, type Scope, Stream, SubscriptionRef } from "effect"
import type * as React from "react"
import * as Component from "./Component.js"
import * as Mutation from "./Mutation.js"
import * as PropertyPath from "./PropertyPath.js"
import * as Result from "./Result.js"
import * as Lens from "./Lens.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")
export type FormTypeId = typeof FormTypeId
export interface Form<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
export interface Form<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly autosubmit: boolean
readonly debounce: Option.Option<Duration.DurationInput>
readonly value: Subscribable.Subscribable<Option.Option<A>>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly error: Subscribable.Subscribable<Option.Option<ParseResult.ParseError>>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>
readonly canSubmit: Subscribable.Subscribable<boolean>
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
readonly path: P
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>
readonly isValidating: Subscribable.Subscribable<boolean, ER, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
}
export class FormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class() implements Form<A, I, R, MA, ME, MR, MP> {
export class FormImpl<out P extends readonly PropertyKey[], in out A, in out I = A, in out ER = never, in out EW = never>
extends Pipeable.Class() implements Form<P, A, I, ER, EW> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: Form<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly autosubmit: boolean,
readonly debounce: Option.Option<Duration.DurationInput>,
readonly value: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly error: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiber: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>>,
readonly runSemaphore: Effect.Semaphore,
readonly fieldCache: Ref.Ref<HashMap.HashMap<FormFieldKey, FormField<unknown, unknown>>>,
readonly path: P,
readonly value: Subscribable.Subscribable<Option.Option<A>, ER, never>,
readonly encodedValue: Lens.Lens<I, ER, EW, never, never>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[], never, never>,
readonly isValidating: Subscribable.Subscribable<boolean, never, never>,
readonly canCommit: Subscribable.Subscribable<boolean, never, never>,
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>,
) {
super()
this.canSubmit = Subscribable.map(
Subscribable.zipLatestAll(this.value, this.error, this.validationFiber, this.mutation.result),
([value, error, validationFiber, result]) => (
Option.isSome(value) &&
Option.isNone(error) &&
Option.isNone(validationFiber) &&
!(Result.isRunning(result) || Result.hasRefreshingFlag(result))
),
)
}
field<const P extends PropertyPath.Paths<I>>(
path: P
): Effect.Effect<FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>> {
const key = new FormFieldKey(path)
return this.fieldCache.pipe(
Effect.map(HashMap.get(key)),
Effect.flatMap(Option.match({
onSome: v => Effect.succeed(v as FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>>),
onNone: () => Effect.tap(
Effect.succeed(makeFormField(this as Form<A, I, R, MA, ME, MR, MP>, path)),
v => Ref.update(this.fieldCache, HashMap.set(key, v as FormField<unknown, unknown>)),
),
})),
)
}
readonly canSubmit: Subscribable.Subscribable<boolean>
get run(): Effect.Effect<void> {
return this.runSemaphore.withPermits(1)(Stream.runForEach(
this.encodedValue.changes.pipe(
Option.isSome(this.debounce) ? Stream.debounce(this.debounce.value) : identity
),
encodedValue => 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(
Ref.set(this.value, Option.some(v)),
Ref.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)),
onNone: () => Effect.void,
}),
}),
Ref.set(this.validationFiber, Option.none()),
),
)).pipe(
Effect.tap(fiber => Ref.set(this.validationFiber, Option.some(fiber))),
Effect.andThen(Fiber.join),
Effect.andThen(value => this.autosubmit
? Effect.asVoid(Effect.forkScoped(this.submitValue(value)))
: Effect.void
),
Effect.forkScoped,
)
),
Effect.provide(this.context),
),
))
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException> {
return this.value.pipe(
Effect.andThen(identity),
Effect.andThen(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>> {
return Effect.whenEffect(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => Result.isFailure(result)
? Option.match(
Chunk.findFirst(
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
e => e._tag === "ParseError",
),
{
onSome: e => Ref.set(this.error, Option.some(e)),
onNone: () => Effect.void,
},
)
: Effect.void
),
this.canSubmit.get,
)
}
}
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: Form<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
readonly autosubmit?: boolean
readonly debounce?: Duration.DurationInput
}
}
export const isForm = (u: unknown): u is Form<readonly PropertyKey[], unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
const filterIssuesByPath = (
issues: readonly ParseResult.ArrayFormatterIssue[],
path: readonly PropertyKey[],
): readonly ParseResult.ArrayFormatterIssue[] => Array.filter(issues, issue =>
issue.path.length >= path.length && Array.every(path, (p, i) => p === issue.path[i])
)
export const focusObjectOn: {
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
key: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends object, I extends object, ER, EW, K extends keyof A & keyof I>(
self: Form<P, A, I, ER, EW>,
key: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, key] as const
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> {
return new FormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
options.autosubmit ?? false,
Option.fromNullable(options.debounce),
yield* SubscriptionRef.make(Option.none<A>()),
yield* SubscriptionRef.make(options.initialEncodedValue),
yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>()),
yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>()),
yield* Effect.makeSemaphore(1),
yield* Ref.make(HashMap.empty<FormFieldKey, FormField<unknown, unknown>>()),
path,
Subscribable.mapOption(form.value, a => a[key]),
Lens.focusObjectOn(form.encodedValue, key),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const focusArrayAt: {
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
<P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
index: number,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly any[], I extends readonly any[], ER, EW>(
self: Form<P, A, I, ER, EW>,
index: number,
): Form<readonly [...P, number], A[number], I[number], ER | Cause.NoSuchElementException, EW | Cause.NoSuchElementException> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect<
Form<A, I, R, MA, ME, Result.forkEffect.OutputContext<MA, ME, MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MA, ME, MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)
export const FormFieldTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidating: Subscribable.Subscribable<boolean>
readonly isSubmitting: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly value: Subscribable.Subscribable<Option.Option<A>, Cause.NoSuchElementException>,
readonly encodedValue: SubscriptionRef.SubscriptionRef<I>,
readonly issues: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidating: Subscribable.Subscribable<boolean>,
readonly isSubmitting: Subscribable.Subscribable<boolean>,
) {
super()
}
}
const FormFieldKeyTypeId: unique symbol = Symbol.for("@effect-fc/Form/FormFieldKey")
type FormFieldKeyTypeId = typeof FormFieldKeyTypeId
class FormFieldKey implements Equal.Equal {
readonly [FormFieldKeyTypeId]: FormFieldKeyTypeId = FormFieldKeyTypeId
constructor(readonly path: PropertyPath.PropertyPath) {}
[Equal.symbol](that: Equal.Equal) {
return isFormFieldKey(that) && PropertyPath.equivalence(this.path, that.path)
}
[Hash.symbol]() {
return Hash.array(this.path)
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
const isFormFieldKey = (u: unknown): u is FormFieldKey => Predicate.hasProperty(u, FormFieldKeyTypeId)
export const makeFormField = <A, I, R, MA, ME, MR, MP, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, MA, ME, MR, MP>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => {
return new FormFieldImpl(
Subscribable.mapEffect(self.value, Option.match({
onSome: v => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
})),
SubscriptionSubRef.makeFromPath(self.encodedValue, path),
Subscribable.mapEffect(self.error, Option.match({
onSome: flow(
ParseResult.ArrayFormatter.formatError,
Effect.map(Array.filter(issue => PropertyPath.equivalence(issue.path, path))),
),
onNone: () => Effect.succeed([]),
})),
Subscribable.map(self.validationFiber, Option.isSome),
Subscribable.map(self.mutation.result, result => Result.isRunning(result) || Result.hasRefreshingFlag(result)),
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, Array.get(index)),
Lens.focusArrayAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
}
})
export const focusTupleAt: {
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW>
<P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
index: K,
): (self: Form<P, A, I, ER, EW>) => Form<readonly [...P, K], A[K], I[K], ER, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A extends readonly [any, ...any[]], I extends readonly [any, ...any[]], ER, EW, K extends number>(
self: Form<P, A, I, ER, EW>,
index: K,
): Form<readonly [...P, K], A[K], I[K], ER, EW> => {
const form = self as FormImpl<P, A, I, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOption(form.value, Array.unsafeGet(index)),
Lens.focusTupleAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export const focusChunkAt: {
<P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
<P extends readonly PropertyKey[], A, I, ER, EW>(
index: number,
): (self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>) => Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW>
} = Function.dual(2, <P extends readonly PropertyKey[], A, I, ER, EW>(
self: Form<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>,
index: number,
): Form<readonly [...P, number], A, I, ER | Cause.NoSuchElementException, EW> => {
const form = self as FormImpl<P, Chunk.Chunk<A>, Chunk.Chunk<I>, ER, EW>
const path = [...form.path, index] as const
return new FormImpl(
path,
Subscribable.mapOptionEffect(form.value, Chunk.get(index)),
Lens.focusChunkAt(form.encodedValue, index),
Subscribable.map(form.issues, issues => filterIssuesByPath(issues, path)),
form.isValidating,
form.canCommit,
form.isCommitting,
)
})
export namespace useInput {
@@ -301,33 +165,39 @@ export namespace useInput {
}
}
export const useInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
export const useInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
form: Form<P, A, I, ER, EW>,
options?: useInput.Options,
): Effect.fn.Return<useInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const internalValueRef = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(field.encodedValue, SubscriptionRef.make),
internalValueRef => Effect.forkScoped(Effect.all([
): Effect.fn.Return<useInput.Success<I>, ER, Scope.Scope> {
const internalValueLens = yield* Component.useOnChange(() => Effect.gen(function*() {
const internalValueLens = yield* Lens.get(form.encodedValue).pipe(
Effect.flatMap(SubscriptionRef.make),
Effect.map(Lens.fromSubscriptionRef),
)
yield* Effect.forkScoped(Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValue, 1),
Stream.drop(form.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(form.encodedValue, internalValue),
),
], { concurrency: "unbounded" })),
), [field, options?.debounce])
], { concurrency: "unbounded", discard: true }))
const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef)
return internalValueLens
}), [form, options?.debounce])
const [value, setValue] = yield* Lens.useState(internalValueLens)
return { value, setValue }
})
@@ -342,55 +212,63 @@ export namespace useOptionalInput {
}
}
export const useOptionalInput = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
export const useOptionalInput = Effect.fnUntraced(function* <P extends readonly PropertyKey[], A, I, ER, EW>(
field: Form<P, A, Option.Option<I>, ER, EW>,
options: useOptionalInput.Options<I>,
): Effect.fn.Return<useOptionalInput.Success<I>, Cause.NoSuchElementException, Scope.Scope> {
const [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap(
Effect.andThen(
field.encodedValue,
): Effect.fn.Return<useOptionalInput.Success<I>, ER, Scope.Scope> {
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 }
})

View File

@@ -1,11 +1,12 @@
import { Effect, Equivalence, Stream } from "effect"
import { Effect, Equivalence, Stream, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
import * as SubscriptionRef from "./SubscriptionRef.js"
export * from "effect-lens/Lens"
export declare namespace useState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
@@ -36,15 +37,15 @@ export const useState = Effect.fnUntraced(function* <A, ER, EW, RR, RW>(
return [reactStateValue, setValue]
})
export declare namespace useFromState {
export declare namespace useFromReactState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useFromState = Effect.fnUntraced(function* <A>(
export const useFromReactState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useFromState.Options<NoInfer<A>>,
options?: useFromReactState.Options<NoInfer<A>>,
): Effect.fn.Return<Lens.Lens<A, never, never, never, never>> {
const lens = yield* Component.useOnMount(() => Effect.map(
SubscriptionRef.make(value),
@@ -59,5 +60,3 @@ export const useFromState = Effect.fnUntraced(function* <A>(
return lens
})
export * from "effect-lens/Lens"

View File

@@ -99,8 +99,10 @@ extends Pipeable.Class() implements Mutation<K, A, E, R, P> {
}
}
export const isMutation = (u: unknown): u is Mutation<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, MutationTypeId)
export declare namespace make {
export interface Options<K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never> {
readonly f: (key: K) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
@@ -111,12 +113,12 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* <const K extends Mutation.AnyKey = never, A = void, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
): Effect.fn.Return<
Mutation<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Mutation<K, A, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | Result.forkEffect.OutputContext<R, P>
> {
return new MutationImpl(
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<A, E, R, P>>(),
yield* Effect.context<Scope.Scope | Result.forkEffect.OutputContext<R, P>>(),
options.f as any,
options.initialProgress as P,

View File

@@ -1,67 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Option } from "effect"
import * as PropertyPath from "./PropertyPath.js"
describe("immutableSet with arrays", () => {
test("sets a top-level array element", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [1], 99)
expect(result).toEqual(Option.some([1, 99, 3]))
})
test("does not mutate the original array", () => {
const arr = [1, 2, 3]
PropertyPath.immutableSet(arr, [0], 42)
expect(arr).toEqual([1, 2, 3])
})
test("sets the first element of an array", () => {
const arr = ["a", "b", "c"]
const result = PropertyPath.immutableSet(arr, [0], "z")
expect(result).toEqual(Option.some(["z", "b", "c"]))
})
test("sets the last element of an array", () => {
const arr = [10, 20, 30]
const result = PropertyPath.immutableSet(arr, [2], 99)
expect(result).toEqual(Option.some([10, 20, 99]))
})
test("sets a nested array element inside an object", () => {
const obj = { tags: ["foo", "bar", "baz"] }
const result = PropertyPath.immutableSet(obj, ["tags", 1], "qux")
expect(result).toEqual(Option.some({ tags: ["foo", "qux", "baz"] }))
})
test("sets a deeply nested value inside an array of objects", () => {
const obj = { items: [{ name: "alice" }, { name: "bob" }] }
const result = PropertyPath.immutableSet(obj, ["items", 0, "name"], "charlie")
expect(result).toEqual(Option.some({ items: [{ name: "charlie" }, { name: "bob" }] }))
})
test("sets a value in a nested array", () => {
const matrix = [[1, 2], [3, 4]]
const result = PropertyPath.immutableSet(matrix, [1, 0], 99)
expect(result).toEqual(Option.some([[1, 2], [99, 4]]))
})
test("returns Option.none() for an out-of-bounds index", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [5], 99)
expect(result).toEqual(Option.none())
})
test("returns Option.none() for a non-numeric key on an array", () => {
const arr = [1, 2, 3]
// @ts-expect-error intentionally wrong key type
const result = PropertyPath.immutableSet(arr, ["length"], 0)
expect(result).toEqual(Option.none())
})
test("empty path returns Option.some of the value itself", () => {
const arr = [1, 2, 3]
const result = PropertyPath.immutableSet(arr, [], [9, 9, 9] as any)
expect(result).toEqual(Option.some([9, 9, 9]))
})
})

View File

@@ -1,98 +0,0 @@
import { Array, Equivalence, Function, Option, Predicate } from "effect"
export type PropertyPath = readonly PropertyKey[]
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
D extends never ? readonly [] :
T extends Seen ? readonly [] :
T extends readonly any[] ? {
[K in keyof T as K extends number ? K : never]:
| readonly [K]
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
} extends infer O
? O[keyof O]
: never
:
T extends object ? {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
:
never
)
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
? Head extends keyof T
? ValueFromPath<T[Head], Tail>
: T extends readonly any[]
? Head extends number
? ValueFromPath<T[number], Tail>
: never
: never
: T
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
export const unsafeGet: {
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): ValueFromPath<T, P> =>
path.reduce((acc: any, key: any) => acc?.[key], self)
)
export const get: {
<T, const P extends Paths<T>>(path: P): (self: T) => Option.Option<ValueFromPath<T, P>>
<T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>>
} = Function.dual(2, <T, const P extends Paths<T>>(self: T, path: P): Option.Option<ValueFromPath<T, P>> =>
path.reduce(
(acc: Option.Option<any>, key: any): Option.Option<any> => Option.isSome(acc)
? Predicate.hasProperty(acc.value, key)
? Option.some(acc.value[key])
: Option.none()
: acc,
Option.some(self),
)
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
const key = Array.head(path as PropertyPath)
if (Option.isNone(key))
return Option.some(value as T)
if (!Predicate.hasProperty(self, key.value))
return Option.none()
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
if (Option.isNone(child))
return child
if (Array.isArray(self))
return typeof key.value === "number"
? Option.some([
...self.slice(0, key.value),
child.value,
...self.slice(key.value + 1),
] as T)
: Option.none()
if (typeof self === "object")
return Option.some(
Object.assign(
Object.create(Object.getPrototypeOf(self)),
{ ...self, [key.value]: child.value },
)
)
return Option.none()
})

View File

@@ -3,7 +3,7 @@ import type * as React from "react"
import * as Component from "./Component.js"
export const usePubSubFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
export const useFromReactiveValues = Effect.fnUntraced(function* <const A extends React.DependencyList>(
values: A
): Effect.fn.Return<PubSub.PubSub<A>, never, Scope.Scope> {
const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded<A>(), PubSub.shutdown))

View File

@@ -266,8 +266,10 @@ extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
}
}
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR>
@@ -281,14 +283,14 @@ export declare namespace make {
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>>(),
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
@@ -308,9 +310,9 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE =
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect<
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<R, P>
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),

View File

@@ -1,4 +1,5 @@
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, Subscribable } from "effect"
import { Cause, Context, Data, Effect, Equal, Exit, type Fiber, Hash, Layer, Match, Pipeable, Predicate, PubSub, pipe, Ref, type Scope, Stream, type Subscribable, SynchronizedRef } from "effect"
import { Lens } from "effect-lens"
export const ResultTypeId: unique symbol = Symbol.for("@effect-fc/Result/Result")
@@ -15,10 +16,6 @@ export type Final<A, E = never, P = never> = (Success<A> | Failure<E>) & ({} | F
export type Flags<P = never> = WillFetch | WillRefresh | Refreshing<P>
export declare namespace Result {
export interface Prototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export type Success<R extends Result<any, any, any>> = [R] extends [Result<infer A, infer _E, infer _P>] ? A : never
export type Failure<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer E, infer _P>] ? E : never
export type Progress<R extends Result<any, any, any>> = [R] extends [Result<infer _A, infer _E, infer P>] ? P : never
@@ -28,21 +25,21 @@ export declare namespace Flags {
export type Keys = keyof WillFetch & WillRefresh & Refreshing<any>
}
export interface Initial extends Result.Prototype {
export interface Initial extends ResultPrototype {
readonly _tag: "Initial"
}
export interface Running<P = never> extends Result.Prototype {
export interface Running<P = never> extends ResultPrototype {
readonly _tag: "Running"
readonly progress: P
}
export interface Success<A> extends Result.Prototype {
export interface Success<A> extends ResultPrototype {
readonly _tag: "Success"
readonly value: A
}
export interface Failure<E = never> extends Result.Prototype {
export interface Failure<E = never> extends ResultPrototype {
readonly _tag: "Failure"
readonly cause: Cause.Cause<E>
}
@@ -61,7 +58,11 @@ export interface Refreshing<P = never> {
}
const ResultPrototype = Object.freeze({
export interface ResultPrototype extends Pipeable.Pipeable, Equal.Equal {
readonly [ResultTypeId]: ResultTypeId
}
export const ResultPrototype: ResultPrototype = Object.freeze({
...Pipeable.Prototype,
[ResultTypeId]: ResultTypeId,
@@ -95,7 +96,7 @@ const ResultPrototype = Object.freeze({
Hash.cached(this),
)
},
} as const satisfies Result.Prototype)
} as const)
export const isResult = (u: unknown): u is Result<unknown, unknown, unknown> => Predicate.hasProperty(u, ResultTypeId)
@@ -162,52 +163,40 @@ export const toExit: {
}
export interface State<A, E = never, P = never> {
readonly get: Effect.Effect<Result<A, E, P>>
readonly set: (v: Result<A, E, P>) => Effect.Effect<void>
}
export const State = <A, E = never, P = never>(): Context.Tag<State<A, E, P>, State<A, E, P>> => Context.GenericTag("@effect-fc/Result/State")
export interface Progress<P = never> {
readonly update: <E, R>(
f: (previous: P) => Effect.Effect<P, E, R>
) => Effect.Effect<void, PreviousResultNotRunningNorRefreshing | E, R>
readonly progress: Lens.Lens<P, PreviousResultNotRunningNorRefreshing, never, never, never>
}
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
export class PreviousResultNotRunningNorRefreshing extends Data.TaggedError("@effect-fc/Result/PreviousResultNotRunningNorRefreshing")<{
readonly previous: Result<unknown, unknown, unknown>
}> {}
export const Progress = <P = never>(): Context.Tag<Progress<P>, Progress<P>> => Context.GenericTag("@effect-fc/Result/Progress")
export const makeProgressLayer = <A, E, P = never>(): Layer.Layer<
Progress<P>,
never,
State<A, E, P>
> => Layer.effect(Progress<P>(), Effect.gen(function*() {
const state = yield* State<A, E, P>()
return {
update: <FE, FR>(f: (previous: P) => Effect.Effect<P, FE, FR>) => Effect.Do.pipe(
Effect.bind("previous", () => Effect.andThen(state.get, previous =>
(isRunning(previous) || hasRefreshingFlag(previous))
? Effect.succeed(previous)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous })),
)),
Effect.bind("progress", ({ previous }) => f(previous.progress)),
Effect.let("next", ({ previous, progress }) => isRunning(previous)
? running(progress)
: refreshing(previous, progress) as Final<A, E, P> & Refreshing<P>
export const makeProgressLayer = <A, E, P = never>(
state: Lens.Lens<Result<A, E, P>, never, never, never, never>
): Layer.Layer<Progress<P> | Progress<never>, never, never> => Layer.succeed(
Progress<P>() as Context.Tag<Progress<P> | Progress<never>, Progress<P> | Progress<never>>,
{
progress: state.pipe(
Lens.mapEffect(
a => (isRunning(a) || hasRefreshingFlag(a))
? Effect.succeed(a)
: Effect.fail(new PreviousResultNotRunningNorRefreshing({ previous: a })),
(_, b) => Effect.succeed(b),
),
Effect.andThen(({ next }) => state.set(next)),
Lens.map(
a => a.progress,
(a, b) => isRunning(a)
? running(b)
: refreshing(a, b) as Final<A, E, P> & Refreshing<P>,
),
}
}))
)
},
)
export namespace unsafeForkEffect {
export type OutputContext<A, E, R, P> = Exclude<R, State<A, E, P> | Progress<P> | Progress<never>>
export type OutputContext<R, P> = Exclude<R, Progress<P> | Progress<never>>
export interface Options<A, E, P> {
readonly initial?: Initial | Final<A, E, P>
@@ -215,55 +204,56 @@ export namespace unsafeForkEffect {
}
}
export const unsafeForkEffect = <A, E, R, P = never>(
export const unsafeForkEffect = Effect.fnUntraced(function* <A, E, R, P = never>(
effect: Effect.Effect<A, E, R>,
options?: unsafeForkEffect.Options<NoInfer<A>, NoInfer<E>, P>,
): Effect.Effect<
): Effect.fn.Return<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
> => Effect.Do.pipe(
Effect.bind("ref", () => Ref.make(options?.initial ?? initial<A, E, P>())),
Effect.bind("pubsub", () => PubSub.unbounded<Result<A, E, P>>()),
Effect.bind("fiber", ({ ref, pubsub }) => Effect.forkScoped(State<A, E, P>().pipe(
Effect.andThen(state => state.set(
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress)
).pipe(
Effect.andThen(effect),
Effect.onExit(exit => Effect.andThen(
state.set(fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
)),
)),
Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: Ref.get(ref),
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
})),
)),
))),
Effect.map(({ ref, pubsub, fiber }) => [
Subscribable.make({
get: Ref.get(ref),
changes: Stream.unwrapScoped(Effect.map(
Scope.Scope | unsafeForkEffect.OutputContext<R, P>
> {
const ref = yield* SynchronizedRef.make<Result<A, E, P>>(options?.initial ?? initial<A, E, P>())
const pubsub = yield* PubSub.unbounded<Result<A, E, P>>()
const state = Lens.make<Result<A, E, P>, never, never, never, never>({
get get() { return Ref.get(ref) },
get changes() {
return Stream.unwrapScoped(Effect.map(
Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
))
},
modify: f => Ref.get(ref).pipe(
Effect.flatMap(f),
Effect.flatMap(([b, a]) => Ref.set(ref, a).pipe(
Effect.as(b),
Effect.zipLeft(PubSub.publish(pubsub, a))
)),
}),
fiber,
]),
) as Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | unsafeForkEffect.OutputContext<A, E, R, P>
>
),
})
const fiber = yield* Effect.gen(function*() {
yield* Lens.set(
state,
(isFinal(options?.initial) && hasWillRefreshFlag(options?.initial))
? refreshing(options.initial, options?.initialProgress) as Result<A, E, P>
: running(options?.initialProgress),
)
return yield* Effect.onExit(effect, exit => Effect.andThen(
Lens.set(state, fromExit(exit)),
Effect.forkScoped(PubSub.shutdown(pubsub)),
))
}).pipe(
Effect.forkScoped,
Effect.provide(makeProgressLayer(state)),
)
return [state, fiber] as const
})
export namespace forkEffect {
export type InputContext<R, P> = R extends Progress<infer X> ? [X] extends [P] ? R : never : R
export type OutputContext<A, E, R, P> = unsafeForkEffect.OutputContext<A, E, R, P>
export type OutputContext<R, P> = unsafeForkEffect.OutputContext<R, P>
export interface Options<A, E, P> extends unsafeForkEffect.Options<A, E, P> {}
}
@@ -274,6 +264,6 @@ export const forkEffect: {
): Effect.Effect<
readonly [result: Subscribable.Subscribable<Result<A, E, P>, never, never>, fiber: Fiber.Fiber<A, E>],
never,
Scope.Scope | forkEffect.OutputContext<A, E, R, P>
Scope.Scope | forkEffect.OutputContext<R, P>
>
} = unsafeForkEffect

View File

@@ -3,8 +3,8 @@ import type * as React from "react"
export const value: {
<S>(prevState: S): (self: React.SetStateAction<S>) => S
<S>(self: React.SetStateAction<S>, prevState: S): S
<S>(prevState: S): (self: React.SetStateAction<S>) => S
} = Function.dual(2, <S>(self: React.SetStateAction<S>, prevState: S): S =>
typeof self === "function"
? (self as (prevState: S) => S)(prevState)

View File

@@ -3,7 +3,7 @@ import * as React from "react"
import * as Component from "./Component.js"
export const useStream: {
export const use: {
<A, E, R>(
stream: Stream.Stream<A, E, R>
): Effect.Effect<Option.Option<A>, never, R>

View File

@@ -0,0 +1,226 @@
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"
import * as Result from "./Result.js"
import * as Subscribable from "./Subscribable.js"
export const SubmittableFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SubmittableForm")
export type SubmittableFormTypeId = typeof SubmittableFormTypeId
export interface SubmittableForm<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Form.Form<readonly [], A, I, never, never> {
readonly [SubmittableFormTypeId]: SubmittableFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R>
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void>
readonly submit: Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException>
}
export class SubmittableFormImpl<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Pipeable.Class() implements SubmittableForm<A, I, R, MA, ME, MR, MP> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SubmittableFormTypeId]: SubmittableFormTypeId = SubmittableFormTypeId
readonly path = [] as const
readonly encodedValue: Lens.Lens<I, never, never, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
readonly isCommitting: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R>,
readonly mutation: Mutation.Mutation<
readonly [value: A, form: SubmittableForm<A, I, R, unknown, unknown, unknown>],
MA, ME, MR, MP
>,
readonly value: Lens.Lens<Option.Option<A>, never, never, never, never>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.encodedValue = Effect.succeed(this).pipe(
Effect.map(self => Lens.make<I, never, never, never, never>({
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,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, never, never> {
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),
Effect.tap(() => Lens.set(this.issues, Array.empty())),
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)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, never, never> {
return Lens.get(this.encodedValue).pipe(
Effect.flatMap(v => Schema.decode(this.schema)(v)),
Effect.option,
Effect.flatMap(v => Lens.set(this.value, v)),
Effect.provide(this.context),
this.runSemaphore.withPermits(1),
)
}
get submit(): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, Cause.NoSuchElementException, never> {
return Lens.get(this.value).pipe(
Effect.flatMap(identity),
Effect.flatMap(value => this.submitValue(value)),
)
}
submitValue(value: A): Effect.Effect<Option.Option<Result.Final<MA, ME, MP>>, never, never> {
return Effect.whenEffect(
Effect.tap(
this.mutation.mutate([value, this as any]),
result => Result.isFailure(result)
? Option.match(
Chunk.findFirst(
Cause.failures(result.cause as Cause.Cause<ParseResult.ParseError>),
e => e._tag === "ParseError",
),
{
onSome: e => Effect.flatMap(
ParseResult.ArrayFormatter.formatError(e),
v => Lens.set(this.issues, v),
),
onNone: () => Effect.void,
},
)
: Effect.void
),
this.canCommit.get,
)
}
}
export const isSubmittableForm = (u: unknown): u is SubmittableForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SubmittableFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends Mutation.make.Options<
readonly [value: NoInfer<A>, form: SubmittableForm<NoInfer<A>, NoInfer<I>, NoInfer<R>, unknown, unknown, unknown>],
MA, ME, MR, MP
> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: make.Options<A, I, R, MA, ME, MR, MP>
): Effect.fn.Return<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> {
return new SubmittableFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R>(),
yield* Mutation.make(options),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<A>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(options.initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out MA = void, in out ME = never, in out MR = never, in out MP = never>
extends make.Options<A, I, R, MA, ME, MR, MP> {}
}
export const service = <A, I = A, R = never, MA = void, ME = never, MR = never, MP = never>(
options: service.Options<A, I, R, MA, ME, MR, MP>
): Effect.Effect<
SubmittableForm<A, I, R, MA, ME, Result.forkEffect.OutputContext<MR, MP>, MP>,
never,
Scope.Scope | R | Result.forkEffect.OutputContext<MR, MP>
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)

View File

@@ -1,8 +1,11 @@
import { Effect, Equivalence, Stream, Subscribable } from "effect"
import { Effect, Equivalence, Stream } from "effect"
import { Subscribable } from "effect-lens"
import * as React from "react"
import * as Component from "./Component.js"
export * from "effect-lens/Subscribable"
export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Subscribable.Subscribable<
@@ -16,7 +19,7 @@ export const zipLatestAll = <const T extends readonly Subscribable.Subscribable<
changes: Stream.zipLatestAll(...elements.map(v => v.changes)),
}) as any
export declare namespace useSubscribables {
export declare namespace useAll {
export type Success<T extends readonly Subscribable.Subscribable<any, any, any>[]> = [T[number]] extends [never]
? never
: { [K in keyof T]: T[K] extends Subscribable.Subscribable<infer A, infer _E, infer _R> ? A : never }
@@ -26,11 +29,11 @@ export declare namespace useSubscribables {
}
}
export const useSubscribables = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
export const useAll = Effect.fnUntraced(function* <const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
elements: T,
options?: useSubscribables.Options<useSubscribables.Success<NoInfer<T>>>,
options?: useAll.Options<useAll.Success<NoInfer<T>>>,
): Effect.fn.Return<
useSubscribables.Success<T>,
useAll.Success<T>,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer E, infer _R> ? E : never,
[T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable<infer _A, infer _E, infer R> ? R : never
> {
@@ -48,5 +51,3 @@ export const useSubscribables = Effect.fnUntraced(function* <const T extends rea
return reactStateValue as any
})
export * from "effect/Subscribable"

View File

@@ -1,61 +0,0 @@
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
import * as SetStateAction from "./SetStateAction.js"
export declare namespace useSubscriptionRefState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefState = Effect.fnUntraced(function* <A>(
ref: SubscriptionRef.SubscriptionRef<A>,
options?: useSubscriptionRefState.Options<NoInfer<A>>,
): Effect.fn.Return<readonly [A, React.Dispatch<React.SetStateAction<A>>]> {
const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref))
yield* Component.useReactEffect(() => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setReactStateValue(v)),
)
), [ref])
const setValue = yield* Component.useCallbackSync(
(setStateAction: React.SetStateAction<A>) => Effect.andThen(
Ref.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)),
v => setReactStateValue(v),
),
[ref],
)
return [reactStateValue, setValue]
})
export declare namespace useSubscriptionRefFromState {
export interface Options<A> {
readonly equivalence?: Equivalence.Equivalence<A>
}
}
export const useSubscriptionRefFromState = Effect.fnUntraced(function* <A>(
[value, setValue]: readonly [A, React.Dispatch<React.SetStateAction<A>>],
options?: useSubscriptionRefFromState.Options<NoInfer<A>>,
): Effect.fn.Return<SubscriptionRef.SubscriptionRef<A>> {
const ref = yield* Component.useOnChange(() => Effect.tap(
SubscriptionRef.make(value),
ref => Effect.forkScoped(
Stream.runForEach(
Stream.changesWith(ref.changes, options?.equivalence ?? Equivalence.strict()),
v => Effect.sync(() => setValue(v)),
)
),
), [setValue])
yield* Component.useReactEffect(() => Ref.set(ref, value), [value])
return ref
})
export * from "effect/SubscriptionRef"

View File

@@ -1,183 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Chunk, Effect, Ref, SubscriptionRef } from "effect"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
describe("SubscriptionSubRef with array refs", () => {
test("creates a subref for a single array element using path", async () => {
const value = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
return subref.get
},
),
)
expect(value).toBe("bob")
})
test("modifies a single array element via subref", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ name: "alice" }, { name: "bob" }, { name: "charlie" }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "name"])
return Effect.flatMap(
Ref.set(subref, "bob-updated"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([{ name: "alice" }, { name: "bob-updated" }, { name: "charlie" }])
})
test("modifies array element at index 0", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([10, 20, 30]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [0])
return Effect.flatMap(
Ref.set(subref, 99),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([99, 20, 30])
})
test("modifies array element at last index", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(["a", "b", "c"]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [2])
return Effect.flatMap(
Ref.set(subref, "z"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual(["a", "b", "z"])
})
test("modifies nested array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([[1, 2], [3, 4], [5, 6]]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, 0])
return Effect.flatMap(
Ref.set(subref, 99),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([[1, 2], [99, 4], [5, 6]])
})
test("uses modifyEffect to transform array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ count: 1 }, { count: 2 }, { count: 3 }]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1, "count"])
return Effect.flatMap(
Ref.update(subref, count => count + 100),
() => Effect.map(Ref.get(parent), parentValue => ({ result: 102, parentValue })),
)
},
),
)
expect(result.result).toBe(102) // count + 100
expect(result.parentValue).toEqual([{ count: 1 }, { count: 102 }, { count: 3 }]) // count + 100
})
test("uses modify to transform array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([10, 20, 30]),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [1])
return Effect.flatMap(
Ref.update(subref, x => x + 5),
() => Effect.map(Ref.get(parent), parentValue => ({ result: 25, parentValue })),
)
},
),
)
expect(result.result).toBe(25) // 20 + 5
expect(result.parentValue).toEqual([10, 25, 30]) // 20 + 5
})
test("makeFromChunkIndex modifies chunk element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(Chunk.make(100, 200, 300)),
parent => {
const subref = SubscriptionSubRef.makeFromChunkIndex(parent, 1)
return Effect.flatMap(
Ref.set(subref, 999),
() => Ref.get(parent),
)
},
),
)
expect(Chunk.toReadonlyArray(result)).toEqual([100, 999, 300])
})
test("makeFromGetSet with custom getter/setter for array element", async () => {
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make([{ id: 1, value: "a" }, { id: 2, value: "b" }]),
parent => {
const subref = SubscriptionSubRef.makeFromGetSet(parent, {
get: arr => arr[0].value,
set: (arr, newValue) => [
{ ...arr[0], value: newValue },
...arr.slice(1),
],
})
return Effect.flatMap(
Ref.set(subref, "updated"),
() => Ref.get(parent),
)
},
),
)
expect(result).toEqual([{ id: 1, value: "updated" }, { id: 2, value: "b" }])
})
test("does not mutate original array when modifying via subref", async () => {
const original = [{ name: "alice" }, { name: "bob" }]
const result = await Effect.runPromise(
Effect.flatMap(
SubscriptionRef.make(original),
parent => {
const subref = SubscriptionSubRef.makeFromPath(parent, [0, "name"])
return Effect.flatMap(
Ref.set(subref, "alice-updated"),
() => Ref.get(parent),
)
},
),
)
expect(original).toEqual([{ name: "alice" }, { name: "bob" }]) // original unchanged
expect(result).toEqual([{ name: "alice-updated" }, { name: "bob" }]) // new value in ref
})
})

View File

@@ -1,186 +0,0 @@
import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Subscribable, SubscriptionRef, SynchronizedRef, type Types, type Unify } from "effect"
import * as PropertyPath from "./PropertyPath.js"
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("@effect-fc/SubscriptionSubRef/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends SubscriptionSubRef.Variance<A, B>, SubscriptionRef.SubscriptionRef<A> {
readonly parent: B
readonly [Unify.typeSymbol]?: unknown
readonly [Unify.unifySymbol]?: SubscriptionSubRefUnify<this>
readonly [Unify.ignoreSymbol]?: SubscriptionSubRefUnifyIgnore
}
export declare namespace SubscriptionSubRef {
export interface Variance<in out A, in out B> {
readonly [SubscriptionSubRefTypeId]: {
readonly _A: Types.Invariant<A>
readonly _B: Types.Invariant<B>
}
}
}
export interface SubscriptionSubRefUnify<A extends { [Unify.typeSymbol]?: any }> extends SubscriptionRef.SubscriptionRefUnify<A> {
SubscriptionSubRef?: () => Extract<A[Unify.typeSymbol], SubscriptionSubRef<any, any>>
}
export interface SubscriptionSubRefUnifyIgnore extends SubscriptionRef.SubscriptionRefUnifyIgnore {
SubscriptionRef?: true
}
const refVariance = { _A: (_: any) => _ }
const synchronizedRefVariance = { _A: (_: any) => _ }
const subscriptionRefVariance = { _A: (_: any) => _ }
const subscriptionSubRefVariance = { _A: (_: any) => _, _B: (_: any) => _ }
class SubscriptionSubRefImpl<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
readonly [Readable.TypeId]: Readable.TypeId = Readable.TypeId
readonly [Subscribable.TypeId]: Subscribable.TypeId = Subscribable.TypeId
readonly [Ref.RefTypeId] = refVariance
readonly [SynchronizedRef.SynchronizedRefTypeId] = synchronizedRefVariance
readonly [SubscriptionRef.SubscriptionRefTypeId] = subscriptionRefVariance
readonly [SubscriptionSubRefTypeId] = subscriptionSubRefVariance
readonly get: Effect.Effect<A>
constructor(
readonly parent: B,
readonly getter: (parentValue: Effect.Effect.Success<B>) => A,
readonly setter: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>,
) {
super()
this.get = Effect.map(this.parent, this.getter)
}
commit() {
return this.get
}
get changes(): Stream.Stream<A> {
return Stream.unwrap(
Effect.map(this.get, a => Stream.concat(
Stream.make(a),
Stream.map(this.parent.changes, this.getter),
))
)
}
modify<C>(f: (a: A) => readonly [C, A]): Effect.Effect<C> {
return this.modifyEffect(a => Effect.succeed(f(a)))
}
modifyEffect<C, E, R>(f: (a: A) => Effect.Effect<readonly [C, A], E, R>): Effect.Effect<C, E, R> {
return Effect.Do.pipe(
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
Effect.bind("ca", ({ b }) => f(this.getter(b))),
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}
}
export const isSubscriptionSubRef = (u: unknown): u is SubscriptionSubRef<unknown, SubscriptionRef.SubscriptionRef<unknown>> => Predicate.hasProperty(u, SubscriptionSubRefTypeId)
export const makeFromGetSet = <A, B extends SubscriptionRef.SubscriptionRef<any>>(
parent: B,
options: {
readonly get: (parentValue: Effect.Effect.Success<B>) => A
readonly set: (parentValue: Effect.Effect.Success<B>, value: A) => Effect.Effect.Success<B>
},
): SubscriptionSubRef<A, B> => new SubscriptionSubRefImpl(parent, options.get, options.set)
export const makeFromPath = <
B extends SubscriptionRef.SubscriptionRef<any>,
const P extends PropertyPath.Paths<Effect.Effect.Success<B>>,
>(
parent: B,
path: P,
): SubscriptionSubRef<PropertyPath.ValueFromPath<Effect.Effect.Success<B>, P>, B> => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(PropertyPath.get(parentValue, path)),
(parentValue, value) => Option.getOrThrow(PropertyPath.immutableSet(parentValue, path, value)),
)
export const makeFromChunkIndex: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
index: number,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>,
index: number,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Chunk.unsafeGet(parentValue, index),
(parentValue, value) => Chunk.replace(parentValue, index, value),
) as any
export const makeFromChunkFindFirst: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findFirstPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findFirstPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findFirst(parentValue, findFirstPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findFirstIndex(parentValue, findFirstPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any
export const makeFromChunkFindLast: {
<B extends SubscriptionRef.SubscriptionRef<Chunk.NonEmptyChunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.NonEmptyChunk<infer A> ? A : never,
B
>
<B extends SubscriptionRef.SubscriptionRef<Chunk.Chunk<any>>>(
parent: B,
findLastPredicate: Predicate.Predicate<Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never>,
): SubscriptionSubRef<
Effect.Effect.Success<B> extends Chunk.Chunk<infer A> ? A : never,
B
>
} = (
parent: SubscriptionRef.SubscriptionRef<Chunk.Chunk<never>>,
findLastPredicate: Predicate.Predicate.Any,
) => new SubscriptionSubRefImpl(
parent,
parentValue => Option.getOrThrow(Chunk.findLast(parentValue, findLastPredicate)),
(parentValue, value) => Option.getOrThrow(Option.andThen(
Chunk.findLastIndex(parentValue, findLastPredicate),
index => Chunk.replace(parentValue, index, value),
)),
) as any

View File

@@ -0,0 +1,224 @@
import { Array, type Context, Effect, Equal, Fiber, flow, Option, ParseResult, Pipeable, Predicate, Schema, type Scope, Stream, SubscriptionRef } from "effect"
import * as Form from "./Form.js"
import * as Lens from "./Lens.js"
import * as Subscribable from "./Subscribable.js"
export const SynchronizedFormTypeId: unique symbol = Symbol.for("@effect-fc/Form/SynchronizedForm")
export type SynchronizedFormTypeId = typeof SynchronizedFormTypeId
export interface SynchronizedForm<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Form.Form<readonly [], A, I, TER, TEW> {
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly validationFiber: Subscribable.Subscribable<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never>
readonly run: Effect.Effect<void, TER>
}
export class SynchronizedFormImpl<
in out A,
in out I = A,
in out R = never,
in out TER = never,
in out TEW = never,
in out TRR = never,
in out TRW = never,
> extends Pipeable.Class() implements SynchronizedForm<A, I, R, TER, TEW, TRR, TRW> {
readonly [Form.FormTypeId]: Form.FormTypeId = Form.FormTypeId
readonly [SynchronizedFormTypeId]: SynchronizedFormTypeId = SynchronizedFormTypeId
readonly path = [] as const
readonly value: Subscribable.Subscribable<Option.Option<A>, never, never>
readonly encodedValue: Lens.Lens<I, TER, TEW, never, never>
readonly isValidating: Subscribable.Subscribable<boolean, never, never>
readonly canCommit: Subscribable.Subscribable<boolean, never, never>
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly context: Context.Context<Scope.Scope | R | TRR | TRW>,
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>,
readonly internalEncodedValue: Lens.Lens<I, never, never, never, never>,
readonly issues: Lens.Lens<readonly ParseResult.ArrayFormatterIssue[], never, never, never, never>,
readonly validationFiber: Lens.Lens<Option.Option<Fiber.Fiber<A, ParseResult.ParseError>>, never, never, never, never>,
readonly isCommitting: Lens.Lens<boolean, never, never>,
readonly runSemaphore: Effect.Semaphore,
) {
super()
this.value = Effect.succeed(this).pipe(
Effect.map(self => Subscribable.make({
get get() { return Effect.provide(Effect.option(self.target.get), self.context) },
get changes() {
return Stream.provideContext(
self.target.changes.pipe(
Stream.map(Option.some),
Stream.catchAll(() => Stream.make(Option.none())),
),
self.context,
)
},
})),
Subscribable.unwrap,
)
this.encodedValue = Effect.succeed(this).pipe(
Effect.map(self => Lens.make<I, TER, TEW, never, never>({
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)),
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.issues, self.validationFiber, self.isCommitting),
([issues, validationFiber, isCommitting]) => (
Array.isEmptyReadonlyArray(issues) &&
Option.isNone(validationFiber) &&
!isCommitting
),
)),
Subscribable.unwrap,
)
}
synchronizeEncodedValue(encodedValue: I): Effect.Effect<void, TER | TEW, never> {
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),
Effect.flatMap(value => Effect.ensuring(
Lens.set(this.isCommitting, true).pipe(
Effect.andThen(Lens.set(this.issues, Array.empty())),
Effect.andThen(Lens.set(this.target, value)),
),
Lens.set(this.isCommitting, false),
)),
Effect.catchIf(
ParseResult.isParseError,
flow(
ParseResult.ArrayFormatter.formatError,
Effect.flatMap(v => Lens.set(this.issues, v)),
),
),
Effect.provide(this.context),
)
}
get run(): Effect.Effect<void, TER, never> {
return this.runSemaphore.withPermits(1)(Effect.provide(
Stream.runForEach(
Stream.drop(this.target.changes, 1),
targetValue => Schema.encode(this.schema, { errors: "all" })(targetValue).pipe(
Effect.flatMap(encodedValue => Effect.whenEffect(
Effect.andThen(
Lens.set(this.issues, Array.empty()),
Lens.set(this.internalEncodedValue, encodedValue),
),
Effect.map(
Lens.get(this.internalEncodedValue),
currentEncodedValue => !Equal.equals(encodedValue, currentEncodedValue),
),
)),
Effect.ignore,
),
),
this.context,
))
}
}
export const isSynchronizedForm = (u: unknown): u is SynchronizedForm<unknown, unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, SynchronizedFormTypeId)
export declare namespace make {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never> {
readonly schema: Schema.Schema<A, I, R>
readonly target: Lens.Lens<A, TER, TEW, TRR, TRW>
readonly initialEncodedValue?: NoInfer<I>
}
}
export const make = Effect.fnUntraced(function* <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: make.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.fn.Return<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> {
const initialEncodedValue = options.initialEncodedValue !== undefined
? options.initialEncodedValue
: yield* Effect.flatMap(
Lens.get(options.target),
Schema.encode(options.schema, { errors: "all" }),
)
return new SynchronizedFormImpl(
options.schema,
yield* Effect.context<Scope.Scope | R | TRR | TRW>(),
options.target,
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(initialEncodedValue)),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make<readonly ParseResult.ArrayFormatterIssue[]>(Array.empty())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(Option.none<Fiber.Fiber<A, ParseResult.ParseError>>())),
Lens.fromSubscriptionRef(yield* SubscriptionRef.make(false)),
yield* Effect.makeSemaphore(1),
)
})
export declare namespace service {
export interface Options<in out A, in out I = A, in out R = never, in out TER = never, in out TEW = never, in out TRR = never, in out TRW = never>
extends make.Options<A, I, R, TER, TEW, TRR, TRW> {}
}
export const service = <A, I = A, R = never, TER = never, TEW = never, TRR = never, TRW = never>(
options: service.Options<A, I, R, TER, TEW, TRR, TRW>
): Effect.Effect<
SynchronizedForm<A, I, R, TER, TEW, TRR, TRW>,
ParseResult.ParseError | TER,
Scope.Scope | R | TRR | TRW
> => Effect.tap(
make(options),
form => Effect.forkScoped(form.run),
)

View File

@@ -5,7 +5,6 @@ export * as Form from "./Form.js"
export * as Lens from "./Lens.js"
export * as Memoized from "./Memoized.js"
export * as Mutation from "./Mutation.js"
export * as PropertyPath from "./PropertyPath.js"
export * as PubSub from "./PubSub.js"
export * as Query from "./Query.js"
export * as QueryClient from "./QueryClient.js"
@@ -13,6 +12,6 @@ export * as ReactRuntime from "./ReactRuntime.js"
export * as Result from "./Result.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js"
export * as SubmittableForm from "./SubmittableForm.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionRef from "./SubscriptionRef.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export * as SynchronizedForm from "./SynchronizedForm.js"

View File

@@ -25,6 +25,7 @@
"noPropertyAccessFromIndexSignature": false,
// Build
"rootDir": "./src",
"outDir": "./dist",
"declaration": true,
"sourceMap": true,

View File

@@ -13,30 +13,30 @@
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@tanstack/react-router": "^1.154.12",
"@tanstack/react-router-devtools": "^1.154.12",
"@tanstack/router-plugin": "^1.154.12",
"@types/react": "^19.2.9",
"@tanstack/react-router": "^1.169.1",
"@tanstack/react-router-devtools": "^1.166.13",
"@tanstack/router-plugin": "^1.167.32",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"globals": "^17.0.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"type-fest": "^5.4.1",
"vite": "^7.3.1"
"@vitejs/plugin-react": "^6.0.1",
"globals": "^17.6.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"type-fest": "^5.6.0",
"vite": "^8.0.10"
},
"dependencies": {
"@effect/platform": "^0.94.2",
"@effect/platform-browser": "^0.74.0",
"@radix-ui/themes": "^3.2.1",
"@effect/platform": "^0.96.1",
"@effect/platform-browser": "^0.76.0",
"@radix-ui/themes": "^3.3.0",
"@typed/id": "^0.17.2",
"effect": "^3.19.15",
"effect": "^3.21.2",
"effect-fc": "workspace:*",
"react-icons": "^5.5.0"
"react-icons": "^5.6.0"
},
"overrides": {
"@types/react": "^19.2.9",
"effect": "^3.19.15",
"react": "^19.2.3"
"@types/react": "^19.2.14",
"effect": "^3.21.2",
"react": "^19.2.5"
}
}

View File

@@ -1,24 +1,26 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option } from "effect"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
export declare namespace TextFieldFormInputView {
export interface Props
extends TextField.RootProps, Form.useInput.Options {
readonly field: Form.FormField<any, string>
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form">, Form.useInput.Options {
readonly form: Form.Form<P, A, string, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(
props: TextFieldFormInputView.Props
export const TextFieldFormInputView = Component.make("TextFieldFormInputView")(function*(
props: TextFieldFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useInput(props.field, props)
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([
props.field.issues,
props.field.isValidating,
props.field.isSubmitting,
const input = yield* Form.useInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
@@ -26,8 +28,8 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={isSubmitting}
{...props}
disabled={isCommitting}
{...Struct.omit(props, "form")}
>
{isValidating &&
<TextField.Slot side="right">
@@ -49,4 +51,6 @@ export class TextFieldFormInputView extends Component.make("TextFieldFormInputVi
})}
</Flex>
)
}) {}
}).pipe(
Component.withSignature<TextFieldFormInputView.Signature>()
)

View File

@@ -1,24 +1,26 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option, Struct } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import type * as React from "react"
export declare namespace TextFieldOptionalFormInputView {
export interface Props
extends Omit<TextField.RootProps, "defaultValue">, Form.useOptionalInput.Options<string> {
readonly field: Form.FormField<any, Option.Option<string>>
export interface Props<out P extends readonly PropertyKey[], A, ER, EW>
extends Omit<TextField.RootProps, "form" | "defaultValue">, Form.useOptionalInput.Options<string> {
readonly form: Form.Form<P, A, Option.Option<string>, ER, EW>
}
export type Signature = <P extends readonly PropertyKey[], A, ER, EW>(props: Props<P, A, ER, EW>) => React.ReactNode
}
export class TextFieldOptionalFormInputView extends Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props
export const TextFieldOptionalFormInputView = Component.make("TextFieldOptionalFormInputView")(function*(
props: TextFieldOptionalFormInputView.Props<readonly PropertyKey[], any, any, any>
) {
const input = yield* Form.useOptionalInput(props.field, props)
const [issues, isValidating, isSubmitting] = yield* Subscribable.useSubscribables([
props.field.issues,
props.field.isValidating,
props.field.isSubmitting,
const input = yield* Form.useOptionalInput(props.form, props)
const [issues, isValidating, isCommitting] = yield* Subscribable.useAll([
props.form.issues,
props.form.isValidating,
props.form.isCommitting,
])
return (
@@ -26,8 +28,8 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={!input.enabled || isSubmitting}
{...Struct.omit(props, "defaultValue")}
disabled={!input.enabled || isCommitting}
{...Struct.omit(props, "form", "defaultValue")}
>
<TextField.Slot side="left">
<Switch
@@ -57,4 +59,6 @@ export class TextFieldOptionalFormInputView extends Component.make("TextFieldOpt
})}
</Flex>
)
}) {}
}).pipe(
Component.withSignature<TextFieldOptionalFormInputView.Signature>()
)

View File

@@ -1,7 +1,7 @@
import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { Component, Form, SubmittableForm, Subscribable } from "effect-fc"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
@@ -40,7 +40,8 @@ const RegisterFormSubmitSchema = Schema.Struct({
})
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Form.service({
scoped: Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
@@ -59,15 +60,22 @@ class RegisterFormService extends Effect.Service<RegisterFormService>()("Registe
yield* Effect.sleep("500 millis")
return yield* Schema.decode(RegisterFormSubmitSchema)(value)
}),
debounce: "500 millis",
})
return {
form,
emailField: Form.focusObjectOn(form, "email"),
passwordField: Form.focusObjectOn(form, "password"),
birthField: Form.focusObjectOn(form, "birth"),
} as const
})
}) {}
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
form.canSubmit,
form.mutation.result,
const [canCommit, submitResult] = yield* Subscribable.useAll([
form.form.canCommit,
form.form.mutation.result,
])
const runPromise = yield* Component.useRunPromise()
@@ -84,24 +92,26 @@ class RegisterFormView extends Component.make("RegisterFormView")(function*() {
<Container width="300">
<form onSubmit={e => {
e.preventDefault()
void runPromise(form.submit)
void runPromise(form.form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInput
field={yield* form.field(["email"])}
form={form.emailField}
debounce="250 millis"
/>
<TextFieldFormInput
field={yield* form.field(["password"])}
form={form.passwordField}
debounce="250 millis"
/>
<TextFieldOptionalFormInput
type="datetime-local"
field={yield* form.field(["birth"])}
form={form.birthField}
defaultValue=""
/>
<Button disabled={!canSubmit}>Submit</Button>
<Button disabled={!canCommit}>Submit</Button>
</Flex>
</form>

View File

@@ -1,8 +1,8 @@
import { HttpClient, type HttpClientError } from "@effect/platform"
import { Button, Container, Flex, Heading, Slider, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream } from "effect"
import { Component, ErrorObserver, Mutation, Query, Result, Subscribable, SubscriptionRef } from "effect-fc"
import { Array, Cause, Chunk, Console, Effect, flow, Match, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Component, ErrorObserver, Lens, Mutation, Query, Result, Subscribable } from "effect-fc"
import { runtime } from "@/runtime"
@@ -16,9 +16,9 @@ const Post = Schema.Struct({
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1)
const key = Stream.map(idRef.changes, id => [id] as const)
const [idLens, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idLens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(1))
const key = Stream.map(idLens.changes, id => [id] as const)
const query = yield* Query.service({
key,
@@ -40,11 +40,11 @@ const ResultView = Component.make("ResultView")(function*() {
),
})
return [idRef, query, mutation] as const
return [idLens, query, mutation] as const
}))
const [id, setId] = yield* SubscriptionRef.useSubscriptionRefState(idRef)
const [queryResult, mutationResult] = yield* Subscribable.useSubscribables([query.result, mutation.result])
const [id, setId] = yield* Lens.useState(idLens)
const [queryResult, mutationResult] = yield* Subscribable.useAll([query.result, mutation.result])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),
@@ -105,7 +105,7 @@ const ResultView = Component.make("ResultView")(function*() {
</div>
<Flex direction="row" justify="center" align="center" gap="1">
<Button onClick={() => runPromise(Effect.andThen(idRef, id => mutation.mutate([id])))}>Mutate</Button>
<Button onClick={() => runPromise(Effect.andThen(Lens.get(idLens), id => mutation.mutate([id])))}>Mutate</Button>
</Flex>
</Flex>
</Container>

View File

@@ -21,7 +21,7 @@ const ResultView = Component.makeUntraced("Result")(function*() {
Effect.tap(Effect.sleep("250 millis")),
Result.forkEffect,
))
const [result] = yield* Subscribable.useSubscribables([resultSubscribable])
const [result] = yield* Subscribable.useAll([resultSubscribable])
yield* Component.useOnMount(() => ErrorObserver.ErrorObserver<HttpClientError.HttpClientError>().pipe(
Effect.andThen(observer => observer.subscribe),

View File

@@ -0,0 +1,88 @@
import { Box, Flex, IconButton } from "@radix-ui/themes"
import { Effect } from "effect"
import { Component, Form, Subscribable, SynchronizedForm } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
export interface EditTodoViewProps {
readonly id: string
}
export class EditTodoView extends Component.make("TodoView")(function*(props: EditTodoViewProps) {
const state = yield* TodosState
const [
indexSubscribable,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexSubscribable = state.getIndexSubscribable(props.id)
const form = yield* SynchronizedForm.service({
schema: TodoFormSchema,
target: state.getElementLens(props.id),
})
return [
indexSubscribable,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}), [props.id])
const [index, size] = yield* Subscribable.useAll([
indexSubscribable,
state.sizeSubscribable,
])
const runSync = yield* Component.useRunSync()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
</Flex>
</Flex>
</Box>
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => runSync(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => runSync(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => runSync(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
</Flex>
)
}) {}

View File

@@ -0,0 +1,78 @@
import { Box, Button, Flex } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Option, Schema } from "effect"
import { Component, Form, Lens, SubmittableForm, Subscribable } from "effect-fc"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { TodoFormSchema } from "./TodoFormSchema"
import { TodosState } from "./TodosState"
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export class NewTodoView extends Component.make("NewTodoView")(function*() {
const state = yield* TodosState
const [
form,
contentField,
completedAtField,
] = yield* Component.useOnMount(() => Effect.gen(function*() {
const form = yield* SubmittableForm.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(yield* makeTodo),
f: ([todo, form]) => Lens.update(state.lens, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Lens.set(form.encodedValue, v)),
),
})
return [
form,
Form.focusObjectOn(form, "content"),
Form.focusObjectOn(form, "completedAt"),
] as const
}))
const [canCommit] = yield* Subscribable.useAll([form.canCommit])
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput
form={contentField}
debounce="250 millis"
/>
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
form={completedAtField}
type="datetime-local"
defaultValue=""
/>
<Button disabled={!canCommit} onClick={() => void runPromise(form.submit)}>
Add
</Button>
</Flex>
</Flex>
</Box>
</Flex>
)
}) {}

View File

@@ -0,0 +1,9 @@
import { Schema } from "effect"
import * as Domain from "@/domain"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
export const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)

View File

@@ -1,136 +0,0 @@
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, type DateTime, Effect, Match, Option, Ref, Schema, Stream } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { TextFieldOptionalFormInputView } from "@/lib/form/TextFieldOptionalFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { TodosState } from "./TodosState"
const TodoFormSchema = Schema.compose(Schema.Struct({
...Domain.Todo.Todo.fields,
completedAt: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
}), Domain.Todo.Todo)
const makeTodo = makeUuid4.pipe(
Effect.map(id => Domain.Todo.Todo.make({
id,
content: "",
completedAt: Option.none(),
})),
Effect.provide(GetRandomValues.CryptoRandom),
)
export type TodoProps = (
| { readonly _tag: "new" }
| { readonly _tag: "edit", readonly id: string }
)
export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
const state = yield* TodosState
const [
indexRef,
form,
contentField,
completedAtField,
] = yield* Component.useOnChange(() => Effect.gen(function*() {
const indexRef = Match.value(props).pipe(
Match.tag("new", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.make(-1) })),
Match.tag("edit", ({ id }) => state.getIndexSubscribable(id)),
Match.exhaustive,
)
const form = yield* Form.service({
schema: TodoFormSchema,
initialEncodedValue: yield* Schema.encode(TodoFormSchema)(
yield* Match.value(props).pipe(
Match.tag("new", () => makeTodo),
Match.tag("edit", ({ id }) => state.getElementRef(id)),
Match.exhaustive,
)
),
f: ([todo, form]) => Match.value(props).pipe(
Match.tag("new", () => Ref.update(state.ref, Chunk.prepend(todo)).pipe(
Effect.andThen(makeTodo),
Effect.andThen(Schema.encode(TodoFormSchema)),
Effect.andThen(v => Ref.set(form.encodedValue, v)),
)),
Match.tag("edit", ({ id }) => Ref.set(state.getElementRef(id), todo)),
Match.exhaustive,
),
autosubmit: props._tag === "edit",
debounce: "250 millis",
})
return [
indexRef,
form,
yield* form.field(["content"]),
yield* form.field(["completedAt"]),
] as const
}), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size, canSubmit] = yield* Subscribable.useSubscribables([
indexRef,
state.sizeSubscribable,
form.canSubmit,
])
const runSync = yield* Component.useRunSync()
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInput = yield* TextFieldFormInputView.use
const TextFieldOptionalFormInput = yield* TextFieldOptionalFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInput field={contentField} />
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldOptionalFormInput
field={completedAtField}
type="datetime-local"
defaultValue=""
/>
{props._tag === "new" &&
<Button disabled={!canSubmit} onClick={() => void runPromise(form.submit)}>
Add
</Button>
}
</Flex>
</Flex>
</Box>
{props._tag === "edit" &&
<Flex direction="column" justify="center" align="center" gap="1">
<IconButton
disabled={index <= 0}
onClick={() => runSync(state.moveLeft(props.id))}
>
<FaArrowUp />
</IconButton>
<IconButton
disabled={index >= size - 1}
onClick={() => runSync(state.moveRight(props.id))}
>
<FaArrowDown />
</IconButton>
<IconButton onClick={() => runSync(state.remove(props.id))}>
<FaDeleteLeft />
</IconButton>
</Flex>
}
</Flex>
)
}) {}

View File

@@ -1,7 +1,7 @@
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Subscribable, SubscriptionSubRef } from "effect-fc"
import { Lens, Subscribable } from "effect-fc"
import { Todo } from "@/domain"
@@ -30,27 +30,29 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: kv.remove(key)
)
const ref = yield* SubscriptionRef.make(yield* readFromLocalStorage)
yield* Effect.forkScoped(ref.changes.pipe(
const lens = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(yield* readFromLocalStorage))
yield* Effect.forkScoped(lens.changes.pipe(
Stream.debounce("500 millis"),
Stream.runForEach(saveToLocalStorage),
))
yield* Effect.addFinalizer(() => ref.pipe(
yield* Effect.addFinalizer(() => Lens.get(lens).pipe(
Effect.andThen(saveToLocalStorage),
Effect.ignore,
))
const sizeSubscribable = Subscribable.make({
get: Effect.andThen(ref, Chunk.size),
get changes() { return Stream.map(ref.changes, Chunk.size) },
})
const getElementRef = (id: string) => SubscriptionSubRef.makeFromChunkFindFirst(ref, v => v.id === id)
const getIndexSubscribable = (id: string) => Subscribable.make({
get: Effect.flatMap(ref, Chunk.findFirstIndex(v => v.id === id)),
get changes() { return Stream.flatMap(ref.changes, Chunk.findFirstIndex(v => v.id === id)) },
})
const sizeSubscribable = Subscribable.map(lens, Chunk.size)
const moveLeft = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
const getElementLens = (id: string) => Lens.mapEffect(
lens,
Chunk.findFirst(v => v.id === id),
(a, b) => Effect.flatMap(
Chunk.findFirstIndex(a, v => v.id === id),
i => Chunk.replaceOption(a, i, b),
)
)
const getIndexSubscribable = (id: string) => Subscribable.mapEffect(lens, Chunk.findFirstIndex(v => v.id === id))
const moveLeft = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("previous", ({ index }) => Chunk.get(todos, index - 1)),
@@ -62,7 +64,7 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos
),
))
const moveRight = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.Do.pipe(
const moveRight = (id: string) => Lens.updateEffect(lens, todos => Effect.Do.pipe(
Effect.bind("index", () => Chunk.findFirstIndex(todos, v => v.id === id)),
Effect.bind("todo", ({ index }) => Chunk.get(todos, index)),
Effect.bind("next", ({ index }) => Chunk.get(todos, index + 1)),
@@ -74,15 +76,15 @@ export class TodosState extends Effect.Service<TodosState>()("TodosState", {
: todos
),
))
const remove = (id: string) => SubscriptionRef.updateEffect(ref, todos => Effect.andThen(
const remove = (id: string) => Lens.updateEffect(lens, todos => Effect.andThen(
Chunk.findFirstIndex(todos, v => v.id === id),
index => Chunk.remove(todos, index),
))
return {
ref,
lens,
sizeSubscribable,
getElementRef,
getElementLens,
getIndexSubscribable,
moveLeft,
moveRight,

View File

@@ -1,30 +1,32 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc"
import { EditTodoView } from "./EditTodoView"
import { NewTodoView } from "./NewTodoView"
import { TodosState } from "./TodosState"
import { TodoView } from "./TodoView"
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* Subscribable.useSubscribables([state.ref])
const [todos] = yield* Subscribable.useAll([state.lens])
yield* Component.useOnMount(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const Todo = yield* TodoView.use
const NewTodo = yield* NewTodoView.use
const EditTodo = yield* EditTodoView.use
return (
<Container>
<Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2">
<Todo _tag="new" />
<NewTodo />
{Chunk.map(todos, todo =>
<Todo key={todo.id} _tag="edit" id={todo.id} />
<EditTodo key={todo.id} id={todo.id} />
)}
</Flex>
</Container>