Compare commits

46 Commits

Author SHA1 Message Date
Julien Valverdé
ef137d07f7 Tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-30 20:38:46 +02:00
Julien Valverdé
917a9590de Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-30 19:52:44 +02:00
Julien Valverdé
a1dc98aa04 Refactoring
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-30 19:43:39 +02:00
Julien Valverdé
9d978e709f Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-30 16:03:00 +02:00
Julien Valverdé
040e671fd3 Working field
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-30 15:54:59 +02:00
Julien Valverdé
47e21f6340 Finished form
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-30 15:12:53 +02:00
Julien Valverdé
58bb84ac2b Form work
Some checks failed
Lint / lint (push) Failing after 11s
2025-09-30 14:56:08 +02:00
Julien Valverdé
74a8714acb Form work
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-30 14:22:21 +02:00
Julien Valverdé
833cfa503a Fix
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-29 15:28:59 +02:00
Julien Valverdé
73134478ed Working form
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-29 14:52:51 +02:00
Julien Valverdé
71d3c77e1a Fix
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-28 21:04:00 +02:00
Julien Valverdé
b9210885a7 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-28 20:54:41 +02:00
Julien Valverdé
ef042ed4b1 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-28 19:16:33 +02:00
Julien Valverdé
3eaa250c28 Fix
Some checks failed
Lint / lint (push) Failing after 57s
2025-09-28 18:01:47 +02:00
Julien Valverdé
70555943c1 Form work
Some checks failed
Lint / lint (push) Failing after 11s
2025-09-27 03:47:21 +02:00
Julien Valverdé
7e57cadd9c Form work
Some checks failed
Lint / lint (push) Failing after 39s
2025-09-27 02:35:15 +02:00
Julien Valverdé
5c748d7dac Form work
Some checks failed
Lint / lint (push) Failing after 11s
2025-09-25 22:25:42 +02:00
Julien Valverdé
b3d6cc6764 Form refactoring
All checks were successful
Lint / lint (push) Successful in 41s
2025-09-25 12:52:44 +02:00
Julien Valverdé
5f531c9b2e Fix
Some checks failed
Lint / lint (push) Failing after 2s
2025-09-25 02:51:23 +02:00
Julien Valverdé
a7471c0d49 Form work
Some checks failed
Lint / lint (push) Failing after 2s
2025-09-25 02:31:46 +02:00
Julien Valverdé
2df12d7f40 Form work
All checks were successful
Lint / lint (push) Successful in 39s
2025-09-24 06:08:02 +02:00
Julien Valverdé
bfd4b7f073 Change useSubscribe to useSubscribables
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-24 01:20:21 +02:00
Julien Valverdé
8c1fed7800 Component refactoring
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-24 00:41:42 +02:00
Julien Valverdé
e2cd7bb671 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-23 22:13:00 +02:00
Julien Valverdé
9033fcbb32 Form refactoring
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-23 19:38:30 +02:00
Julien Valverdé
ed1efb41cd Fix
All checks were successful
Lint / lint (push) Successful in 12s
2025-09-23 18:15:11 +02:00
Julien Valverdé
46ef35040a Fix?
All checks were successful
Lint / lint (push) Successful in 11s
2025-09-23 06:02:01 +02:00
Julien Valverdé
7545b4bb30 Form V2
All checks were successful
Lint / lint (push) Successful in 43s
2025-09-23 05:28:23 +02:00
Julien Valverdé
8b5074bd56 Form work
Some checks failed
Lint / lint (push) Failing after 11s
2025-08-28 05:52:39 +02:00
Julien Valverdé
450d11cc3e Fix
All checks were successful
Lint / lint (push) Successful in 41s
2025-08-28 04:42:23 +02:00
Julien Valverdé
f6e69a05fd Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-27 19:58:57 +02:00
Julien Valverdé
a11180d03e Form tests
All checks were successful
Lint / lint (push) Successful in 12s
2025-08-27 06:11:15 +02:00
Julien Valverdé
8ba0189472 Fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-27 05:41:31 +02:00
Julien Valverdé
64114f0208 Form tests
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-27 05:40:39 +02:00
Julien Valverdé
ecc37515b8 Fix
All checks were successful
Lint / lint (push) Successful in 12s
2025-08-27 05:27:55 +02:00
Julien Valverdé
483e90dc67 Form done
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-27 05:25:22 +02:00
Julien Valverdé
fa6dc2a921 Form work
Some checks failed
Lint / lint (push) Failing after 42s
2025-08-27 05:08:37 +02:00
Julien Valverdé
e61d8ecae1 Form work
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-26 05:35:53 +02:00
Julien Valverdé
7c5a118717 Moved Form
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-26 04:15:23 +02:00
Julien Valverdé
edb4b7ccd8 Form work
All checks were successful
Lint / lint (push) Successful in 39s
2025-08-26 04:13:22 +02:00
Julien Valverdé
30f3ef2353 Tests
Some checks failed
Lint / lint (push) Failing after 11s
2025-08-25 23:06:44 +02:00
Julien Valverdé
ae3aa00a06 Refactoring
Some checks failed
Lint / lint (push) Failing after 11s
2025-08-25 18:23:20 +02:00
Julien Valverdé
4845e34b0a Form work
Some checks failed
Lint / lint (push) Failing after 48s
2025-08-25 05:24:42 +02:00
Julien Valverdé
3613d421f3 Form work
Some checks failed
Lint / lint (push) Failing after 11s
2025-08-24 15:11:31 +02:00
Julien Valverdé
993e2c1038 Form type
Some checks failed
Lint / lint (push) Failing after 42s
2025-08-24 13:48:09 +02:00
Julien Valverdé
8057163ad0 Dependencies fix
All checks were successful
Lint / lint (push) Successful in 11s
2025-08-23 03:16:07 +02:00
23 changed files with 679 additions and 71 deletions

View File

@@ -12,7 +12,10 @@
},
"packages/effect-fc": {
"name": "effect-fc",
"version": "0.1.2",
"version": "0.1.3",
"dependencies": {
"@typed/async-data": "^0.13.1",
},
"devDependencies": {
"@effect/language-service": "^0.35.2",
},
@@ -51,6 +54,7 @@
"globals": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"type-fest": "^4.41.0",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3",
},
@@ -799,6 +803,8 @@
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"typescript-eslint": ["typescript-eslint@8.40.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.40.0", "@typescript-eslint/parser": "8.40.0", "@typescript-eslint/typescript-estree": "8.40.0", "@typescript-eslint/utils": "8.40.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Xvd2l+ZmFDPEt4oj1QEXzA4A2uUK6opvKu3eGN9aGjB8au02lIVcLyi375w94hHyejTOmzIU77L8ol2sRg9n7Q=="],

View File

@@ -45,5 +45,8 @@
},
"devDependencies": {
"@effect/language-service": "^0.35.2"
},
"dependencies": {
"@typed/async-data": "^0.13.1"
}
}

View File

@@ -1,4 +1,4 @@
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, String, Tracer, type Types, type Utils } from "effect"
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, Tracer, type Types, type Utils } from "effect"
import * as React from "react"
import { Hooks } from "./hooks/index.js"
import * as Memo from "./Memo.js"
@@ -331,13 +331,9 @@ export const make: (
) => make.Gen & make.NonGen)
) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => {
if (typeof spanNameOrBody !== "string") {
const displayName = displayNameFromBody(spanNameOrBody)
return Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: displayName
? Effect.fn(displayName)(spanNameOrBody as any, ...pipeables as [])
: Effect.fn(spanNameOrBody as any, ...pipeables),
displayName,
body: Effect.fn(spanNameOrBody as any, ...pipeables),
}),
ComponentProto,
)
@@ -347,26 +343,34 @@ export const make: (
return (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []),
displayName: displayNameFromBody(body) ?? spanNameOrBody,
displayName: spanNameOrBody,
}),
ComponentProto,
)
}
}
export const makeUntraced: make.Gen & make.NonGen = (
body: Function,
...pipeables: any[]
) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(body as any, ...pipeables as []),
displayName: displayNameFromBody(body),
}),
ComponentProto,
export const makeUntraced: (
& make.Gen
& make.NonGen
& ((name: string) => make.Gen & make.NonGen)
) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => (
typeof spanNameOrBody !== "string"
? Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []),
}),
ComponentProto,
)
: (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(body, ...pipeables as []),
displayName: spanNameOrBody,
}),
ComponentProto,
)
)
const displayNameFromBody = (body: Function) => !String.isEmpty(body.name) ? body.name : undefined
export const withOptions: {
<T extends Component<any, any, any, any>>(
options: Partial<Component.Options>

View File

@@ -0,0 +1,398 @@
import * as AsyncData from "@typed/async-data"
import { Array, Cause, Chunk, Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, pipe, Pipeable, Ref, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect"
import type { NoSuchElementException } from "effect/Cause"
import * as React from "react"
import { Hooks } from "./hooks/index.js"
import { PropertyPath, Subscribable as SubscribableInternal, SubscriptionSubRef } from "./types/index.js"
export const FormTypeId: unique symbol = Symbol.for("effect-fc/Form")
export type FormTypeId = typeof FormTypeId
export interface Form<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly schema: Schema.Schema<A, I, R>,
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>,
readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>
readonly submitStateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<SA, SE>>,
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>
}
class FormImpl<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Class() implements Form<A, I, R, SA, SE, SR> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>,
readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>,
readonly submitStateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<SA, SE>>,
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export namespace make {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>
}
}
export const make: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
) {
const valueRef = yield* SubscriptionRef.make(Option.none<A>())
const errorRef = yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>())
const validationFiberRef = yield* SubscriptionRef.make(Option.none<Fiber.Fiber<void, never>>())
const submitStateRef = yield* SubscriptionRef.make(AsyncData.noData<SA, SE>())
return new FormImpl(
options.schema,
options.submit,
valueRef,
yield* SubscriptionRef.make(options.initialEncodedValue),
errorRef,
validationFiberRef,
submitStateRef,
pipe(
<A>([value, error, validationFiber, submitState]: readonly [
Option.Option<A>,
Option.Option<ParseResult.ParseError>,
Option.Option<Fiber.Fiber<void, never>>,
AsyncData.AsyncData<SA, SE>,
]) => Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && !AsyncData.isLoading(submitState),
filter => SubscribableInternal.make({
get: Effect.map(Effect.all([valueRef, errorRef, validationFiberRef, submitStateRef]), filter),
get changes() {
return Stream.map(
Stream.zipLatestAll(
valueRef.changes,
errorRef.changes,
validationFiberRef.changes,
submitStateRef.changes,
),
filter,
)
},
}),
),
)
})
export const run = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<void, never, Scope.Scope | R> => Stream.runForEach(
self.encodedValueRef.changes,
encodedValue => self.validationFiberRef.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.addFinalizer(() => SubscriptionRef.set(self.validationFiberRef, Option.none())).pipe(
Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)),
Effect.exit,
Effect.andThen(flow(
Exit.matchEffect({
onSuccess: v => Effect.andThen(
SubscriptionRef.set(self.valueRef, Option.some(v)),
SubscriptionRef.set(self.errorRef, Option.none()),
),
onFailure: c => Option.match(
Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"),
{
onSome: e => SubscriptionRef.set(self.errorRef, Option.some(e)),
onNone: () => Effect.void,
},
),
}),
Effect.uninterruptible,
)),
Effect.scoped,
Effect.forkScoped,
)
),
Effect.andThen(fiber => SubscriptionRef.set(self.validationFiberRef, Option.some(fiber)))
),
)
export const submit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<Option.Option<AsyncData.AsyncData<SA, SE>>, NoSuchElementException, SR> => Effect.whenEffect(
self.valueRef.pipe(
Effect.andThen(identity),
Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())),
Effect.andThen(flow(
self.submit,
Effect.exit,
Effect.map(Exit.match({
onSuccess: a => AsyncData.success(a),
onFailure: e => AsyncData.failure(e),
})),
Effect.tap(v => Ref.set(self.submitStateRef, v))
)),
),
self.canSubmitSubscribable.get,
)
export namespace service {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const service = <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R | Scope.Scope> => Effect.tap(
make(options),
form => Effect.forkScoped(run(form)),
)
export const field = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => new FormFieldImpl(
pipe(
Option.match({
onSome: (v: A) => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
}),
filter => SubscribableInternal.make({
get: Effect.flatMap(self.valueRef, filter),
get changes() { return Stream.flatMap(self.valueRef.changes, filter) },
}),
),
SubscriptionSubRef.makeFromPath(self.encodedValueRef, path),
pipe(
Option.match({
onSome: (v: ParseResult.ParseError) => Effect.andThen(
ParseResult.ArrayFormatter.formatError(v),
Array.filter(issue => PropertyPath.equivalence(issue.path, path)),
),
onNone: () => Effect.succeed([]),
}),
filter => SubscribableInternal.make({
get: Effect.flatMap(self.errorRef.get, filter),
get changes() { return Stream.flatMap(self.errorRef.changes, filter) },
}),
),
pipe(
Option.isSome,
filter => SubscribableInternal.make({
get: Effect.map(self.validationFiberRef.get, filter),
get changes() { return Stream.map(self.validationFiberRef.changes, filter) },
}),
),
pipe(
AsyncData.isLoading,
filter => SubscribableInternal.make({
get: Effect.map(self.submitStateRef, filter),
get changes() { return Stream.map(self.submitStateRef.changes, filter) },
}),
),
)
export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>
readonly isSubmittingSubscribable: 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 valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>,
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export namespace useForm {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const useForm: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
) {
const form = yield* Hooks.useMemo(() => make(options), deps)
yield* Hooks.useFork(() => run(form), [form])
return form
})
export const useSubmit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<
() => Promise<Option.Option<AsyncData.AsyncData<SA, SE>>>,
never,
SR
> => Hooks.useCallbackPromise(() => submit(self), [self])
export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
path: P,
): FormField<
PropertyPath.ValueFromPath<A, P>,
PropertyPath.ValueFromPath<I, P>
> => React.useMemo(() => field(self, path), [self, ...path])
export namespace useInput {
export interface Options {
readonly debounce?: Duration.DurationInput
}
export interface Result<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
}
}
export const useInput: {
<A, I>(
field: FormField<A, I>,
options?: useInput.Options,
): Effect.Effect<useInput.Result<I>, NoSuchElementException>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
options?: useInput.Options,
) {
const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field])
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
),
),
Stream.runForEach(
internalValueRef.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
internalValue => Ref.set(field.encodedValueRef, internalValue),
),
], { concurrency: "unbounded" }), [field, internalValueRef, options?.debounce])
return { value, setValue }
})
export namespace useOptionalInput {
export interface Options<I> extends useInput.Options {
readonly defaultValue: I
}
export interface Result<T> extends useInput.Result<T> {
readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
}
}
export const useOptionalInput: {
<A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
): Effect.Effect<useOptionalInput.Result<I>, NoSuchElementException>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
) {
const [enabledRef, internalValueRef] = yield* Hooks.useMemo(() => Effect.andThen(
field.encodedValueRef,
Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
}),
), [field])
const [enabled, setEnabled] = yield* Hooks.useRefState(enabledRef)
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Ref.set(enabledRef, true),
Ref.set(internalValueRef, v),
),
onNone: () => Effect.andThen(
Ref.set(enabledRef, false),
Ref.set(internalValueRef, options.defaultValue),
),
}),
Effect.andThen(
Effect.all([enabledRef, internalValueRef]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
),
),
Stream.runForEach(
enabledRef.changes.pipe(
Stream.zipLatest(internalValueRef.changes),
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()),
),
], { concurrency: "unbounded" }), [field, enabledRef, internalValueRef, options.debounce])
return { enabled, setEnabled, value, setValue }
})

View File

@@ -12,5 +12,5 @@ export * from "./useRefFromState.js"
export * from "./useRefState.js"
export * from "./useScope.js"
export * from "./useStreamFromReactiveValues.js"
export * from "./useSubscribe.js"
export * from "./useSubscribables.js"
export * from "./useSubscribeStream.js"

View File

@@ -5,7 +5,7 @@ import { useCallbackSync } from "../useCallbackSync.js"
import { useFork } from "../useFork.js"
import { useOnce } from "../useOnce.js"
import { useRefState } from "../useRefState.js"
import { useSubscribe } from "../useSubscribe.js"
import { useSubscribables } from "../useSubscribables.js"
export namespace useOptionalInput {
@@ -101,7 +101,7 @@ export const useOptionalInput: {
[options.schema, options.ref, internalRef, enabledRef],
)
const [enabled] = yield* useSubscribe(enabledRef)
const [enabled] = yield* useSubscribables(enabledRef)
const [value, setValue] = yield* useRefState(internalRef)
return { value, setValue, enabled, setEnabled, error }
})

View File

@@ -4,7 +4,7 @@ import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"
export const useSubscribe: {
export const useSubscribables: {
<const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Effect.Effect<

View File

@@ -1,4 +1,5 @@
export * as Component from "./Component.js"
export * as Form from "./Form.js"
export * as Memo from "./Memo.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as Suspense from "./Suspense.js"

View File

@@ -1,34 +1,34 @@
import { Array, Function, Option, Predicate } from "effect"
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> = [] | (
D extends never ? [] :
T extends Seen ? [] :
T extends readonly any[] ? ArrayPaths<T, D, Seen | T> :
T extends object ? ObjectPaths<T, D, Seen | T> :
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 ArrayPaths<T extends readonly any[], D extends number, Seen> = {
[K in keyof T as K extends number ? K : never]:
| [K]
| [K, ...Paths<T[K], Prev[D], Seen>]
} extends infer O
? O[keyof O]
: never
export type ObjectPaths<T extends object, D extends number, Seen> = {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? [K] | [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer Tail]
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[]
@@ -38,8 +38,8 @@ export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer
: never
: T
export type AnyPath = readonly PropertyKey[]
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>
@@ -64,16 +64,16 @@ export const get: {
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => ValueFromPath<T, P>
<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 AnyPath)
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 AnyPath)), value)
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
if (Option.isNone(child))
return child

View File

@@ -0,0 +1,37 @@
import { Array, Function, Option, Predicate, Schema } from "effect"
import type { Simplify } from "effect/Types"
export type SchemaFromPath<S, P extends readonly any[]> = S extends Schema.Schema.Any
? P extends [infer Head, ...infer Tail]
? Head extends keyof S["Type"]
? (
S extends Schema.TupleType<infer Elements, infer Rest> ? (
Head extends keyof Elements ? SchemaFromPath<Elements[Head], Tail> :
Head extends keyof Rest ? SchemaFromPath<Rest, Tail> :
never
) :
S extends Schema.Array$<infer Value> ? SchemaFromPath<Value, Tail> :
S extends Schema.Struct<infer Fields> ? SchemaFromPath<Fields[Head], Tail> :
never
)
: never
: S
: never
const TestSchema = Schema.Struct({
allUsers: Schema.Array(Schema.Struct({
name: Schema.String
})),
admins: Schema.Tuple(
Schema.Struct({
name: Schema.Literal("Gneugneu")
}),
Schema.Struct({
name: Schema.Literal("AAAAYA")
}),
),
})
type S = SchemaFromPath<typeof TestSchema, ["admins", 0, "name"]>
type T = number extends keyof typeof TestSchema.fields.admins.elements ? true : false

View File

@@ -78,7 +78,7 @@ extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
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] }) => Ref.set(this.parent, this.setter(b, a))),
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}

View File

@@ -25,6 +25,7 @@
"globals": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"type-fest": "^4.41.0",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3"
},

View File

@@ -0,0 +1,46 @@
import { Callout, Flex, Spinner, TextField } from "@radix-ui/themes"
import { Array, Option } from "effect"
import { Component, Form } from "effect-fc"
import { useSubscribables } from "effect-fc/hooks"
export interface TextFieldFormInputProps
extends TextField.RootProps, Form.useInput.Options {
readonly field: Form.FormField<any, string>
}
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
const { value, setValue } = yield* Form.useInput(props.field, props)
const [issues, isValidating, isSubmitting] = yield* useSubscribables(
props.field.issuesSubscribable,
props.field.isValidatingSubscribable,
props.field.isSubmittingSubscribable,
)
return (
<Flex direction="column" gap="1">
<TextField.Root
value={value}
onChange={e => setValue(e.target.value)}
disabled={isSubmitting}
{...props}
>
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}) {}

View File

@@ -15,7 +15,7 @@ export const TextAreaInput = <A, R>(options: {
React.JSX.Element,
ParseResult.ParseError,
R
> => Component.makeUntraced(function* TextFieldInput(props) {
> => Component.makeUntraced("TextFieldInput")(function* TextFieldInput(props) {
const input = yield* useInput({ ...options, ...props })
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),

View File

@@ -18,7 +18,7 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
readonly optional?: O
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}) => Component.makeUntraced(function* TextFieldInput(props: O extends true
}) => Component.makeUntraced("TextFieldInput")(function* TextFieldInput(props: O extends true
? TextFieldOptionalInputProps<A, R>
: TextFieldInputProps<A, R>
) {

View File

@@ -9,12 +9,18 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevInputRouteImport } from './routes/dev/input'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
@@ -44,6 +50,7 @@ const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -59,6 +67,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -68,15 +77,23 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/input' | '/dev/memo'
to:
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
@@ -85,6 +102,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevInputRoute: typeof DevInputRoute
DevMemoRoute: typeof DevMemoRoute
@@ -92,6 +110,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
@@ -133,6 +158,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevInputRoute: DevInputRoute,
DevMemoRoute: DevMemoRoute,

View File

@@ -51,7 +51,7 @@ const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
// )
class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent
yield* Effect.sleep("500 millis") // Async operation
@@ -69,7 +69,7 @@ class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
) {}
class MemoizedAsyncComponent extends Memo.memo(AsyncComponent) {}
class SubComponent extends Component.makeUntraced(function* SubComponent() {
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Hooks.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}

View File

@@ -4,7 +4,7 @@ import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Schema, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOnce, useRefState } from "effect-fc/hooks"
import { useOnce } from "effect-fc/hooks"
const IntFromString = Schema.NumberFromString.pipe(Schema.int())
@@ -12,18 +12,18 @@ const IntFromString = Schema.NumberFromString.pipe(Schema.int())
const IntTextFieldInput = TextFieldInput({ schema: IntFromString })
const StringTextFieldInput = TextFieldInput({ schema: Schema.String })
const Input = Component.makeUntraced(function* Input() {
const Input = Component.makeUntraced("Input")(function*() {
const IntTextFieldInputFC = yield* IntTextFieldInput
const StringTextFieldInputFC = yield* StringTextFieldInput
const intRef1 = yield* useOnce(() => SubscriptionRef.make(0))
const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
// const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
const stringRef = yield* useOnce(() => SubscriptionRef.make(""))
// yield* useFork(() => Stream.runForEach(intRef1.changes, Console.log), [intRef1])
const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
// const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
const [str, setStr] = yield* useRefState(stringRef)
// const [str, setStr] = yield* useRefState(stringRef)
return (
<Container>

View File

@@ -7,7 +7,7 @@ import { Component, Memo } from "effect-fc"
import * as React from "react"
const RouteComponent = Component.makeUntraced(function* RouteComponent() {
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
const [value, setValue] = React.useState("")
return (
@@ -25,7 +25,7 @@ const RouteComponent = Component.makeUntraced(function* RouteComponent() {
Component.withRuntime(runtime.context)
)
class SubComponent extends Component.makeUntraced(function* SubComponent() {
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
return <Text>{id}</Text>
}) {}

View File

@@ -0,0 +1,86 @@
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { runtime } from "@/runtime"
import { Button, Container, Flex } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Effect, ParseResult, Schema } from "effect"
import { Component, Form } from "effect-fc"
import { useContext, useSubscribables } from "effect-fc/hooks"
const email = Schema.pattern<typeof Schema.String>(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
{
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)
const RegisterFormSchema = Schema.Struct({
email: Schema.String.pipe(email),
password: Schema.String.pipe(Schema.minLength(3)),
})
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
scoped: Form.service({
schema: Schema.transformOrFail(
Schema.encodedSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
},
),
initialEncodedValue: { email: "", password: "" },
submit: () => Effect.andThen(
Effect.sleep("500 millis"),
Effect.sync(() => alert("Done!")),
),
})
}) {}
class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() {
const form = yield* RegisterForm
const submit = yield* Form.useSubmit(form)
const [canSubmit] = yield* useSubscribables(form.canSubmitSubscribable)
const TextFieldFormInputFC = yield* TextFieldFormInput
return (
<Container>
<form onSubmit={e => {
e.preventDefault()
void submit()
}}>
<Flex direction="column" gap="2">
<TextFieldFormInputFC
field={Form.useField(form, ["email"])}
debounce="200 millis"
/>
<TextFieldFormInputFC
field={Form.useField(form, ["password"])}
debounce="200 millis"
/>
<Button disabled={!canSubmit}>Submit</Button>
</Flex>
</form>
</Container>
)
}) {}
const RegisterRoute = Component.makeUntraced(function* RegisterRoute() {
const context = yield* useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" })
const RegisterRouteFC = yield* Effect.provide(RegisterPage, context)
return <RegisterRouteFC />
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/form")({
component: RegisterRoute
})

View File

@@ -9,7 +9,7 @@ import { useContext } from "effect-fc/hooks"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const Index = Component.makeUntraced("Index")(function*() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)

View File

@@ -6,7 +6,7 @@ import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, DateTime, Effect, Match, Option, Ref, Runtime, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useMemo, useOnce, useSubscribe } from "effect-fc/hooks"
import { useMemo, useOnce, useSubscribables } from "effect-fc/hooks"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
@@ -31,7 +31,7 @@ export type TodoProps = (
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps) {
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) {
const runtime = yield* Effect.runtime()
const state = yield* TodosState
@@ -51,7 +51,7 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
// eslint-disable-next-line react-hooks/exhaustive-deps
), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size] = yield* useSubscribe(indexRef, state.sizeSubscribable)
const [index, size] = yield* useSubscribables(indexRef, state.sizeSubscribable)
const StringTextAreaInputFC = yield* StringTextAreaInput
const OptionalDateTimeInputFC = yield* OptionalDateTimeInput

View File

@@ -1,14 +1,14 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { useOnce, useSubscribables } from "effect-fc/hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.makeUntraced(function* Todos() {
export class Todos extends Component.makeUntraced("Todos")(function*() {
const state = yield* TodosState
const [todos] = yield* useSubscribe(state.ref)
const [todos] = yield* useSubscribables(state.ref)
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"),