0.1.4 (#5)
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -16,14 +16,14 @@ Documentation is currently being written. In the meantime, you can take a look a
|
||||
## What writing components looks like
|
||||
```typescript
|
||||
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"),
|
||||
@@ -49,7 +49,7 @@ export class Todos extends Component.makeUntraced(function* Todos() {
|
||||
|
||||
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)
|
||||
|
||||
|
||||
8
packages/effect-fc/biome.json
Normal file
8
packages/effect-fc/biome.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**"]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "effect-fc",
|
||||
"description": "Write React function components with Effect",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"./README.md",
|
||||
@@ -17,33 +17,32 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./hooks": {
|
||||
"types": "./dist/hooks/index.d.ts",
|
||||
"default": "./dist/hooks/index.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/*.d.ts",
|
||||
"default": "./dist/*.js"
|
||||
}
|
||||
"./*": [
|
||||
{
|
||||
"types": "./dist/*/index.d.ts",
|
||||
"default": "./dist/*/index.js"
|
||||
},
|
||||
{
|
||||
"types": "./dist/*.d.ts",
|
||||
"default": "./dist/*.js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:biome": "biome lint",
|
||||
"pack": "npm pack",
|
||||
"clean:cache": "rm -f tsconfig.tsbuildinfo",
|
||||
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:node": "rm -rf node_modules"
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"effect": "^3.15.0",
|
||||
"react": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "^0.35.2"
|
||||
"dependencies": {
|
||||
"@typed/async-data": "^0.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("effect-fc/Suspense")
|
||||
export const TypeId: unique symbol = Symbol.for("effect-fc/Async")
|
||||
export type TypeId = typeof TypeId
|
||||
|
||||
export interface Suspense extends Suspense.Options {
|
||||
export interface Async extends Async.Options {
|
||||
readonly [TypeId]: TypeId
|
||||
}
|
||||
|
||||
export namespace Suspense {
|
||||
export namespace Async {
|
||||
export interface Options {
|
||||
readonly defaultFallback?: React.ReactNode
|
||||
}
|
||||
@@ -23,13 +24,13 @@ const SuspenseProto = Object.freeze({
|
||||
[TypeId]: TypeId,
|
||||
|
||||
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
|
||||
this: Component.Component<P, A, E, R> & Suspense,
|
||||
this: Component.Component<P, A, E, R> & Async,
|
||||
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
|
||||
scope: Scope.Scope,
|
||||
) {
|
||||
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
|
||||
|
||||
return ({ fallback, name, ...props }: Suspense.Props) => {
|
||||
return ({ fallback, name, ...props }: Async.Props) => {
|
||||
const promise = Runtime.runPromise(runtimeRef.current)(
|
||||
Effect.provideService(this.body(props as P), Scope.Scope, scope)
|
||||
)
|
||||
@@ -44,19 +45,19 @@ const SuspenseProto = Object.freeze({
|
||||
} as const)
|
||||
|
||||
|
||||
export const isSuspense = (u: unknown): u is Suspense => Predicate.hasProperty(u, TypeId)
|
||||
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
|
||||
|
||||
export const suspense = <T extends Component.Component<any, any, any, any>>(
|
||||
export const async = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T
|
||||
): (
|
||||
& Omit<T, keyof Component.Component.AsComponent<T>>
|
||||
& Component.Component<
|
||||
Component.Component.Props<T> & Suspense.Props,
|
||||
Component.Component.Props<T> & Async.Props,
|
||||
Component.Component.Success<T>,
|
||||
Component.Component.Error<T>,
|
||||
Component.Component.Context<T>
|
||||
>
|
||||
& Suspense
|
||||
& Async
|
||||
) => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
@@ -66,16 +67,16 @@ export const suspense = <T extends Component.Component<any, any, any, any>>(
|
||||
)
|
||||
|
||||
export const withOptions: {
|
||||
<T extends Component.Component<any, any, any, any> & Suspense>(
|
||||
options: Partial<Suspense.Options>
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
options: Partial<Async.Options>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component<any, any, any, any> & Suspense>(
|
||||
<T extends Component.Component<any, any, any, any> & Async>(
|
||||
self: T,
|
||||
options: Partial<Suspense.Options>,
|
||||
options: Partial<Async.Options>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Suspense>(
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
|
||||
self: T,
|
||||
options: Partial<Suspense.Options>,
|
||||
options: Partial<Async.Options>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, String, Tracer, type Types, type Utils } from "effect"
|
||||
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
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"
|
||||
import * as Hooks from "./Hooks/index.js"
|
||||
import { Memoized } from "./index.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("effect-fc/Component")
|
||||
@@ -12,12 +14,12 @@ extends
|
||||
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
|
||||
Component.Options
|
||||
{
|
||||
new(_: never): {}
|
||||
new(_: never): Record<string, never>
|
||||
readonly [TypeId]: TypeId
|
||||
readonly ["~Props"]: P
|
||||
readonly ["~Success"]: A
|
||||
readonly ["~Error"]: E
|
||||
readonly ["~Context"]: R
|
||||
readonly "~Props": P
|
||||
readonly "~Success": A
|
||||
readonly "~Error": E
|
||||
readonly "~Context": R
|
||||
|
||||
/** @internal */
|
||||
readonly body: (props: P) => Effect.Effect<A, E, R>
|
||||
@@ -53,6 +55,7 @@ const ComponentProto = Object.freeze({
|
||||
this: Component<P, A, E, R>
|
||||
) {
|
||||
const self = this
|
||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
|
||||
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
|
||||
|
||||
@@ -67,7 +70,7 @@ const ComponentProto = Object.freeze({
|
||||
const FC = React.useMemo(() => {
|
||||
const f: React.FC<P> = self.makeFunctionComponent(runtimeRef, scope)
|
||||
f.displayName = self.displayName ?? "Anonymous"
|
||||
return Memo.isMemo(self)
|
||||
return Memoized.isMemoized(self)
|
||||
? React.memo(f, self.propsAreEqual)
|
||||
: f
|
||||
}, [scope])
|
||||
@@ -331,13 +334,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 +346,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>
|
||||
|
||||
411
packages/effect-fc/src/Form.ts
Normal file
411
packages/effect-fc/src/Form.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import * as AsyncData from "@typed/async-data"
|
||||
import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, pipe, Ref, Schema, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect"
|
||||
import type { NoSuchElementException } from "effect/Cause"
|
||||
import * as React from "react"
|
||||
import * as Hooks from "./Hooks/index.js"
|
||||
import * as PropertyPath from "./PropertyPath.js"
|
||||
import * as SubscribableInternal from "./Subscribable.js"
|
||||
import * as SubscriptionSubRef from "./SubscriptionSubRef.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 debounce: Option.Option<Duration.DurationInput>
|
||||
|
||||
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 debounce: Option.Option<Duration.DurationInput>,
|
||||
|
||||
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 const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
|
||||
|
||||
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>,
|
||||
readonly debounce?: Duration.DurationInput,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
Option.fromNullable(options.debounce),
|
||||
|
||||
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.pipe(
|
||||
Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity
|
||||
),
|
||||
|
||||
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 const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
|
||||
|
||||
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), [options.debounce, ...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>
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: individual path components need to be compared
|
||||
> => 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<T> extends useInput.Options {
|
||||
readonly defaultValue: T
|
||||
}
|
||||
|
||||
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 }
|
||||
})
|
||||
@@ -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"
|
||||
@@ -1,11 +1,11 @@
|
||||
import { type Duration, Effect, Equal, Equivalence, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { SetStateAction } from "../../../types/index.js"
|
||||
import * as SetStateAction from "../../SetStateAction.js"
|
||||
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 }
|
||||
})
|
||||
@@ -11,8 +11,10 @@ export const useCallbackPromise: {
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
|
||||
runtimeRef.current = yield* Effect.runtime<R>()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(callback(...args)), deps)
|
||||
})
|
||||
@@ -11,8 +11,10 @@ export const useCallbackSync: {
|
||||
callback: (...args: Args) => Effect.Effect<A, E, R>,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
|
||||
runtimeRef.current = yield* Effect.runtime<R>()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(callback(...args)), deps)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
import { closeScope } from "./internal.js"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
|
||||
|
||||
export const useEffect: {
|
||||
@@ -24,5 +24,6 @@ export const useEffect: {
|
||||
() => closeScope(scope, runtime, options)
|
||||
),
|
||||
Runtime.runSync(runtime),
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
), deps)
|
||||
})
|
||||
@@ -27,5 +27,6 @@ export const useFork: {
|
||||
...options,
|
||||
finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork",
|
||||
})
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
}, deps)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
import { closeScope } from "./internal.js"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
|
||||
|
||||
export const useLayoutEffect: {
|
||||
@@ -24,5 +24,6 @@ export const useLayoutEffect: {
|
||||
() => closeScope(scope, runtime, options)
|
||||
),
|
||||
Runtime.runSync(runtime),
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
), deps)
|
||||
})
|
||||
@@ -12,5 +12,6 @@ export const useMemo: {
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const runtime = yield* Effect.runtime()
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
|
||||
import { Effect, Equivalence, Ref, Stream, type SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { SetStateAction } from "../../types/index.js"
|
||||
import * as SetStateAction from "../SetStateAction.js"
|
||||
import { useCallbackSync } from "./useCallbackSync.js"
|
||||
import { useFork } from "./useFork.js"
|
||||
import { useOnce } from "./useOnce.js"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, ExecutionStrategy, Ref, Runtime, Scope } from "effect"
|
||||
import * as React from "react"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
import { closeScope } from "./internal.js"
|
||||
import type { ScopeOptions } from "./ScopeOptions.js"
|
||||
|
||||
|
||||
export const useScope: {
|
||||
@@ -12,6 +12,7 @@ export const useScope: {
|
||||
} = Effect.fnUntraced(function*(deps, options) {
|
||||
const runtime = yield* Effect.runtime()
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed
|
||||
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
|
||||
Ref.make(true),
|
||||
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
|
||||
@@ -30,6 +31,7 @@ export const useScope: {
|
||||
Effect.map(scope => () => closeScope(scope, runtime, options)),
|
||||
),
|
||||
})
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
|
||||
), deps)
|
||||
|
||||
return scope
|
||||
@@ -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<
|
||||
@@ -16,6 +16,7 @@ export const useSubscribeStream: {
|
||||
initialValue?: A,
|
||||
) {
|
||||
const [reactStateValue, setReactStateValue] = React.useState(
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed
|
||||
React.useMemo(() => initialValue
|
||||
? Option.some(initialValue)
|
||||
: Option.none(),
|
||||
@@ -1,50 +0,0 @@
|
||||
import { type Equivalence, Function, Predicate } from "effect"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("effect-fc/Memo")
|
||||
export type TypeId = typeof TypeId
|
||||
|
||||
export interface Memo<P> extends Memo.Options<P> {
|
||||
readonly [TypeId]: TypeId
|
||||
}
|
||||
|
||||
export namespace Memo {
|
||||
export interface Options<P> {
|
||||
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const MemoProto = Object.freeze({
|
||||
[TypeId]: TypeId
|
||||
} as const)
|
||||
|
||||
|
||||
export const isMemo = (u: unknown): u is Memo<unknown> => Predicate.hasProperty(u, TypeId)
|
||||
|
||||
export const memo = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T
|
||||
): T & Memo<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, MemoProto),
|
||||
Object.getPrototypeOf(self),
|
||||
)),
|
||||
)
|
||||
|
||||
export const withOptions: {
|
||||
<T extends Component.Component<any, any, any, any> & Memo<any>>(
|
||||
options: Partial<Memo.Options<Component.Component.Props<T>>>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component<any, any, any, any> & Memo<any>>(
|
||||
self: T,
|
||||
options: Partial<Memo.Options<Component.Component.Props<T>>>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memo<any>>(
|
||||
self: T,
|
||||
options: Partial<Memo.Options<Component.Component.Props<T>>>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
51
packages/effect-fc/src/Memoized.ts
Normal file
51
packages/effect-fc/src/Memoized.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { type Equivalence, Function, Predicate } from "effect"
|
||||
import type * as Component from "./Component.js"
|
||||
|
||||
|
||||
export const TypeId: unique symbol = Symbol.for("effect-fc/Memoized")
|
||||
export type TypeId = typeof TypeId
|
||||
|
||||
export interface Memoized<P> extends Memoized.Options<P> {
|
||||
readonly [TypeId]: TypeId
|
||||
}
|
||||
|
||||
export namespace Memoized {
|
||||
export interface Options<P> {
|
||||
readonly propsAreEqual?: Equivalence.Equivalence<P>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const MemoizedProto = Object.freeze({
|
||||
[TypeId]: TypeId
|
||||
} as const)
|
||||
|
||||
|
||||
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
|
||||
|
||||
export const memoized = <T extends Component.Component<any, any, any, any>>(
|
||||
self: T
|
||||
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self),
|
||||
Object.freeze(Object.setPrototypeOf(
|
||||
Object.assign({}, MemoizedProto),
|
||||
Object.getPrototypeOf(self),
|
||||
)),
|
||||
)
|
||||
|
||||
export const withOptions: {
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>
|
||||
): (self: T) => T
|
||||
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||
): T
|
||||
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
|
||||
self: T,
|
||||
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
|
||||
): T => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, self, options),
|
||||
Object.getPrototypeOf(self),
|
||||
))
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
|
||||
import { Effect, type Layer, ManagedRuntime, Predicate, type Runtime } from "effect"
|
||||
import * as React from "react"
|
||||
|
||||
@@ -6,7 +7,7 @@ export const TypeId: unique symbol = Symbol.for("effect-fc/ReactRuntime")
|
||||
export type TypeId = typeof TypeId
|
||||
|
||||
export interface ReactRuntime<R, ER> {
|
||||
new(_: never): {}
|
||||
new(_: never): Record<string, never>
|
||||
readonly [TypeId]: TypeId
|
||||
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
|
||||
readonly context: React.Context<Runtime.Runtime<R>>
|
||||
@@ -23,6 +24,7 @@ export const make = <R, ER>(
|
||||
): ReactRuntime<R, ER> => Object.setPrototypeOf(
|
||||
Object.assign(function() {}, {
|
||||
runtime: ManagedRuntime.make(layer, memoMap),
|
||||
// biome-ignore lint/style/noNonNullAssertion: context initialization
|
||||
context: React.createContext<Runtime.Runtime<R>>(null!),
|
||||
}),
|
||||
ReactRuntimeProto,
|
||||
@@ -48,16 +50,14 @@ export const Provider = <R, ER>(
|
||||
)
|
||||
}
|
||||
|
||||
namespace ProviderInner {
|
||||
export interface Props<R, ER> {
|
||||
readonly runtime: ReactRuntime<R, ER>
|
||||
readonly promise: Promise<Runtime.Runtime<R>>
|
||||
readonly children?: React.ReactNode
|
||||
}
|
||||
interface ProviderInnerProps<R, ER> {
|
||||
readonly runtime: ReactRuntime<R, ER>
|
||||
readonly promise: Promise<Runtime.Runtime<R>>
|
||||
readonly children?: React.ReactNode
|
||||
}
|
||||
|
||||
const ProviderInner = <R, ER>(
|
||||
{ runtime, promise, children }: ProviderInner.Props<R, ER>
|
||||
{ runtime, promise, children }: ProviderInnerProps<R, ER>
|
||||
): React.ReactNode => React.createElement(
|
||||
runtime.context,
|
||||
{ value: React.use(promise) },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Su
|
||||
import * as PropertyPath from "./PropertyPath.js"
|
||||
|
||||
|
||||
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("effect-fc/types/SubscriptionSubRef")
|
||||
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("effect-fc/SubscriptionSubRef")
|
||||
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
|
||||
|
||||
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./Hooks/index.js"
|
||||
export * as Hooks from "./Hooks/index.js"
|
||||
@@ -1,4 +1,10 @@
|
||||
export * as Async from "./Async.js"
|
||||
export * as Component from "./Component.js"
|
||||
export * as Memo from "./Memo.js"
|
||||
export * as Form from "./Form.js"
|
||||
export * as Hooks from "./Hooks/index.js"
|
||||
export * as Memoized from "./Memoized.js"
|
||||
export * as PropertyPath from "./PropertyPath.js"
|
||||
export * as ReactRuntime from "./ReactRuntime.js"
|
||||
export * as Suspense from "./Suspense.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as Subscribable from "./Subscribable.js"
|
||||
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export * as PropertyPath from "./PropertyPath.js"
|
||||
export * as SetStateAction from "./SetStateAction.js"
|
||||
export * as Subscribable from "./Subscribable.js"
|
||||
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"
|
||||
@@ -26,7 +26,12 @@
|
||||
|
||||
// Build
|
||||
"outDir": "./dist",
|
||||
"declaration": true
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
|
||||
"plugins": [
|
||||
{ "name": "@effect/language-service" }
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["./src"]
|
||||
|
||||
8
packages/example/biome.json
Normal file
8
packages/example/biome.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["./src/**", "!src/routeTree.gen.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -5,43 +5,40 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:eslint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"lint:biome": "biome lint",
|
||||
"preview": "vite preview",
|
||||
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
|
||||
"clean:dist": "rm -rf dist",
|
||||
"clean:modules": "rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/language-service": "^0.35.2",
|
||||
"@eslint/js": "^9.34.0",
|
||||
"@tanstack/react-router": "^1.131.27",
|
||||
"@tanstack/react-router-devtools": "^1.131.27",
|
||||
"@tanstack/router-plugin": "^1.131.27",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.1",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"vite": "^7.1.3"
|
||||
"@tanstack/react-router": "^1.132.31",
|
||||
"@tanstack/react-router-devtools": "^1.132.31",
|
||||
"@tanstack/router-plugin": "^1.132.31",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"globals": "^16.4.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"type-fest": "^5.0.1",
|
||||
"vite": "^7.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.90.6",
|
||||
"@effect/platform-browser": "^0.70.0",
|
||||
"@effect/platform": "^0.92.1",
|
||||
"@effect/platform-browser": "^0.72.0",
|
||||
"@radix-ui/themes": "^3.2.1",
|
||||
"@typed/async-data": "^0.13.1",
|
||||
"@typed/id": "^0.17.2",
|
||||
"@typed/lazy-ref": "^0.3.3",
|
||||
"effect": "^3.17.9",
|
||||
"effect": "^3.18.1",
|
||||
"effect-fc": "workspace:*",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^19.1.11",
|
||||
"effect": "^3.17.9",
|
||||
"react": "^19.1.1"
|
||||
"@types/react": "^19.2.0",
|
||||
"effect": "^3.18.1",
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
77
packages/example/src/lib/form/TextFieldFormInput.tsx
Normal file
77
packages/example/src/lib/form/TextFieldFormInput.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
|
||||
import { Array, Option } from "effect"
|
||||
import { Component, Form, Hooks } from "effect-fc"
|
||||
|
||||
|
||||
interface Props
|
||||
extends TextField.RootProps, Form.useInput.Options {
|
||||
readonly optional?: false
|
||||
readonly field: Form.FormField<any, string>
|
||||
}
|
||||
|
||||
interface OptionalProps
|
||||
extends Omit<TextField.RootProps, "defaultValue">, Form.useOptionalInput.Options<string> {
|
||||
readonly optional: true
|
||||
readonly field: Form.FormField<any, Option.Option<string>>
|
||||
}
|
||||
|
||||
export type TextFieldFormInputProps = Props | OptionalProps
|
||||
|
||||
|
||||
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(
|
||||
function*(props: TextFieldFormInputProps) {
|
||||
const input: (
|
||||
| { readonly optional: true } & Form.useOptionalInput.Result<string>
|
||||
| { readonly optional: false } & Form.useInput.Result<string>
|
||||
) = props.optional
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
||||
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
|
||||
: { optional: false, ...yield* Form.useInput(props.field, props) }
|
||||
|
||||
const [issues, isValidating, isSubmitting] = yield* Hooks.useSubscribables(
|
||||
props.field.issuesSubscribable,
|
||||
props.field.isValidatingSubscribable,
|
||||
props.field.isSubmittingSubscribable,
|
||||
)
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap="1">
|
||||
<TextField.Root
|
||||
value={input.value}
|
||||
onChange={e => input.setValue(e.target.value)}
|
||||
disabled={(input.optional && !input.enabled) || isSubmitting}
|
||||
{...props}
|
||||
>
|
||||
{input.optional &&
|
||||
<TextField.Slot side="left">
|
||||
<Switch
|
||||
size="1"
|
||||
checked={input.enabled}
|
||||
onCheckedChange={input.setEnabled}
|
||||
/>
|
||||
</TextField.Slot>
|
||||
}
|
||||
|
||||
{isValidating &&
|
||||
<TextField.Slot side="right">
|
||||
<Spinner />
|
||||
</TextField.Slot>
|
||||
}
|
||||
|
||||
{props.children}
|
||||
</TextField.Root>
|
||||
|
||||
{Option.match(Array.head(issues), {
|
||||
onSome: issue => (
|
||||
<Callout.Root>
|
||||
<Callout.Text>{issue.message}</Callout.Text>
|
||||
</Callout.Root>
|
||||
),
|
||||
|
||||
onNone: () => <></>,
|
||||
})}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
) {}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Callout, Flex, TextArea, TextAreaProps } from "@radix-ui/themes"
|
||||
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: effect-fc HOC */
|
||||
import { Callout, Flex, TextArea, type TextAreaProps } from "@radix-ui/themes"
|
||||
import { Array, type Equivalence, Option, ParseResult, type Schema, Struct } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { useInput } from "effect-fc/hooks"
|
||||
import { useInput } from "effect-fc/Hooks"
|
||||
import * as React from "react"
|
||||
|
||||
|
||||
@@ -15,7 +16,7 @@ export const TextAreaInput = <A, R>(options: {
|
||||
React.JSX.Element,
|
||||
ParseResult.ParseError,
|
||||
R
|
||||
> => Component.makeUntraced(function* TextFieldInput(props) {
|
||||
> => Component.makeUntraced("TextFieldInput")(function*(props) {
|
||||
const input = yield* useInput({ ...options, ...props })
|
||||
const issue = React.useMemo(() => input.error.pipe(
|
||||
Option.map(ParseResult.ArrayFormatter.formatErrorSync),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/** biome-ignore-all lint/correctness/useHookAtTopLevel: effect-fc HOC */
|
||||
import { Callout, Checkbox, Flex, TextField } from "@radix-ui/themes"
|
||||
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
|
||||
import { Component, Memo } from "effect-fc"
|
||||
import { useInput, useOptionalInput } from "effect-fc/hooks"
|
||||
import { Array, type Equivalence, Option, ParseResult, type Schema, Struct } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { useInput, useOptionalInput } from "effect-fc/Hooks"
|
||||
import * as React from "react"
|
||||
|
||||
|
||||
@@ -18,7 +19,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*(props: O extends true
|
||||
? TextFieldOptionalInputProps<A, R>
|
||||
: TextFieldInputProps<A, R>
|
||||
) {
|
||||
@@ -28,12 +29,10 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
|
||||
) = options.optional
|
||||
? {
|
||||
optional: true,
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
...yield* useOptionalInput({ ...options, ...props as TextFieldOptionalInputProps<A, R> }),
|
||||
}
|
||||
: {
|
||||
optional: false,
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
...yield* useInput({ ...options, ...props as TextFieldInputProps<A, R> }),
|
||||
}
|
||||
|
||||
@@ -67,4 +66,4 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
|
||||
}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(Memo.memo)
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { runtime } from "@/runtime"
|
||||
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Effect } from "effect"
|
||||
import { Component, Memo, Suspense } from "effect-fc"
|
||||
import { Hooks } from "effect-fc/hooks"
|
||||
import { Async, Component, Hooks, Memoized } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
// Generator version
|
||||
@@ -51,7 +50,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
|
||||
@@ -64,12 +63,12 @@ class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(
|
||||
Suspense.suspense,
|
||||
Suspense.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||
Async.async,
|
||||
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
|
||||
) {}
|
||||
class MemoizedAsyncComponent extends Memo.memo(AsyncComponent) {}
|
||||
class MemoizedAsyncComponent extends Memoized.memoized(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>
|
||||
}) {}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { TextFieldInput } from "@/lib/input/TextFieldInput"
|
||||
import { runtime } from "@/runtime"
|
||||
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 { Component, Hooks, Memoized } from "effect-fc"
|
||||
import { TextFieldInput } from "@/lib/input/TextFieldInput"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const IntFromString = Schema.NumberFromString.pipe(Schema.int())
|
||||
@@ -12,18 +11,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 stringRef = yield* useOnce(() => SubscriptionRef.make(""))
|
||||
const intRef1 = yield* Hooks.useOnce(() => SubscriptionRef.make(0))
|
||||
// const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
|
||||
const stringRef = yield* Hooks.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>
|
||||
@@ -33,7 +32,7 @@ const Input = Component.makeUntraced(function* Input() {
|
||||
</Container>
|
||||
)
|
||||
}).pipe(
|
||||
Memo.memo,
|
||||
Memoized.memoized,
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { runtime } from "@/runtime"
|
||||
import { Flex, Text, TextField } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Effect } from "effect"
|
||||
import { Component, Memo } from "effect-fc"
|
||||
import { Component, Memoized } from "effect-fc"
|
||||
import * as React from "react"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
const RouteComponent = Component.makeUntraced(function* RouteComponent() {
|
||||
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
|
||||
const [value, setValue] = React.useState("")
|
||||
|
||||
return (
|
||||
@@ -25,12 +25,12 @@ 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>
|
||||
}) {}
|
||||
|
||||
class MemoizedSubComponent extends Memo.memo(SubComponent) {}
|
||||
class MemoizedSubComponent extends Memoized.memoized(SubComponent) {}
|
||||
|
||||
export const Route = createFileRoute("/dev/memo")({
|
||||
component: RouteComponent,
|
||||
|
||||
99
packages/example/src/routes/form.tsx
Normal file
99
packages/example/src/routes/form.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Button, Container, Flex } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Option, ParseResult, Schema } from "effect"
|
||||
import { Component, Form, Hooks } from "effect-fc"
|
||||
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
import { runtime } from "@/runtime"
|
||||
|
||||
|
||||
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)),
|
||||
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
|
||||
})
|
||||
|
||||
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
|
||||
scoped: Form.service({
|
||||
schema: RegisterFormSchema.pipe(
|
||||
Schema.compose(
|
||||
Schema.transformOrFail(
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
Schema.typeSchema(RegisterFormSchema),
|
||||
{
|
||||
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
|
||||
encode: ParseResult.succeed,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
initialEncodedValue: { email: "", password: "", birth: Option.none() },
|
||||
submit: v => Effect.sleep("500 millis").pipe(
|
||||
Effect.andThen(Console.log(v)),
|
||||
Effect.andThen(Effect.sync(() => alert("Done!"))),
|
||||
),
|
||||
debounce: "500 millis",
|
||||
})
|
||||
}) {}
|
||||
|
||||
class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() {
|
||||
const form = yield* RegisterForm
|
||||
const submit = yield* Form.useSubmit(form)
|
||||
const [canSubmit] = yield* Hooks.useSubscribables(form.canSubmitSubscribable)
|
||||
|
||||
const TextFieldFormInputFC = yield* TextFieldFormInput
|
||||
|
||||
|
||||
return (
|
||||
<Container width="300">
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void submit()
|
||||
}}>
|
||||
<Flex direction="column" gap="2">
|
||||
<TextFieldFormInputFC
|
||||
field={Form.useField(form, ["email"])}
|
||||
/>
|
||||
|
||||
<TextFieldFormInputFC
|
||||
field={Form.useField(form, ["password"])}
|
||||
/>
|
||||
|
||||
<TextFieldFormInputFC
|
||||
optional
|
||||
type="datetime-local"
|
||||
field={Form.useField(form, ["birth"])}
|
||||
defaultValue=""
|
||||
/>
|
||||
|
||||
<Button disabled={!canSubmit}>Submit</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Container>
|
||||
)
|
||||
}) {}
|
||||
|
||||
|
||||
export const Route = createFileRoute("/form")({
|
||||
component: Component.makeUntraced("RegisterRoute")(function*() {
|
||||
const RegisterRouteFC = yield* Effect.provide(
|
||||
RegisterPage,
|
||||
yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }),
|
||||
)
|
||||
|
||||
return <RegisterRouteFC />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
})
|
||||
@@ -1,17 +1,18 @@
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component, Hooks } from "effect-fc"
|
||||
import { runtime } from "@/runtime"
|
||||
import { Todos } from "@/todo/Todos"
|
||||
import { TodosState } from "@/todo/TodosState.service"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { useContext } from "effect-fc/hooks"
|
||||
|
||||
|
||||
const TodosStateLive = TodosState.Default("todos")
|
||||
|
||||
const Index = Component.makeUntraced(function* Index() {
|
||||
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
|
||||
const TodosFC = yield* Effect.provide(Todos, context)
|
||||
const Index = Component.makeUntraced("Index")(function*() {
|
||||
const TodosFC = yield* Effect.provide(
|
||||
Todos,
|
||||
yield* Hooks.useContext(TodosStateLive, { finalizerExecutionMode: "fork" }),
|
||||
)
|
||||
|
||||
return <TodosFC />
|
||||
}).pipe(
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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, Hooks, Memoized, Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
||||
import { FaDeleteLeft } from "react-icons/fa6"
|
||||
import * as Domain from "@/domain"
|
||||
import { TextAreaInput } from "@/lib/input/TextAreaInput"
|
||||
import { TextFieldInput } from "@/lib/input/TextFieldInput"
|
||||
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
|
||||
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 { Subscribable, SubscriptionSubRef } from "effect-fc/types"
|
||||
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
|
||||
import { FaDeleteLeft } from "react-icons/fa6"
|
||||
import { TodosState } from "./TodosState.service"
|
||||
|
||||
|
||||
@@ -31,11 +29,11 @@ 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
|
||||
|
||||
const { ref, indexRef, contentRef, completedAtRef } = yield* useMemo(() => Match.value(props).pipe(
|
||||
const { ref, indexRef, contentRef, completedAtRef } = yield* Hooks.useMemo(() => Match.value(props).pipe(
|
||||
Match.tag("new", () => Effect.Do.pipe(
|
||||
Effect.bind("ref", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
|
||||
Effect.let("indexRef", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.empty })),
|
||||
@@ -48,10 +46,9 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
|
||||
|
||||
Effect.let("contentRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["content"])),
|
||||
Effect.let("completedAtRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["completedAt"])),
|
||||
// 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* Hooks.useSubscribables(indexRef, state.sizeSubscribable)
|
||||
|
||||
const StringTextAreaInputFC = yield* StringTextAreaInput
|
||||
const OptionalDateTimeInputFC = yield* OptionalDateTimeInput
|
||||
@@ -67,7 +64,7 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
|
||||
<OptionalDateTimeInputFC
|
||||
type="datetime-local"
|
||||
ref={completedAtRef}
|
||||
defaultValue={yield* useOnce(() => DateTime.now)}
|
||||
defaultValue={yield* Hooks.useOnce(() => DateTime.now)}
|
||||
/>
|
||||
|
||||
{props._tag === "new" &&
|
||||
@@ -109,4 +106,6 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
|
||||
}
|
||||
</Flex>
|
||||
)
|
||||
}).pipe(Memo.memo) {}
|
||||
}).pipe(
|
||||
Memoized.memoized
|
||||
) {}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
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 { Component, Hooks } from "effect-fc"
|
||||
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* Hooks.useSubscribables(state.ref)
|
||||
|
||||
yield* useOnce(() => Effect.andThen(
|
||||
yield* Hooks.useOnce(() => Effect.andThen(
|
||||
Console.log("Todos mounted"),
|
||||
Effect.addFinalizer(() => Console.log("Todos unmounted")),
|
||||
))
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Todo } from "@/domain"
|
||||
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/types"
|
||||
import { Subscribable, SubscriptionSubRef } from "effect-fc"
|
||||
import { Todo } from "@/domain"
|
||||
|
||||
|
||||
export class TodosState extends Effect.Service<TodosState>()("TodosState", {
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"plugins": [
|
||||
{
|
||||
"name": "@effect/language-service"
|
||||
}
|
||||
{ "name": "@effect/language-service" }
|
||||
]
|
||||
},
|
||||
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user