diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 9f0e1e5..4bb01f4 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -1,8 +1,7 @@ /** 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 { Context, Effect, Effectable, ExecutionStrategy, Exit, Function, Layer, ManagedRuntime, Predicate, Ref, Runtime, Scope, Tracer, type Types, type Utils } from "effect" import * as React from "react" -import * as Hooks from "./Hooks/index.js" import { Memoized } from "./index.js" @@ -46,6 +45,11 @@ export namespace Component { } } +export interface ScopeOptions { + readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy +} + const ComponentProto = Object.freeze({ ...Effectable.CommitPrototype, @@ -60,7 +64,7 @@ const ComponentProto = Object.freeze({ runtimeRef.current = yield* Effect.runtime>() return React.useRef(function ScopeProvider(props: P) { - const scope = Runtime.runSync(runtimeRef.current)(Hooks.useScope( + const scope = Runtime.runSync(runtimeRef.current)(useScope( Array.from( Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() ), @@ -408,6 +412,55 @@ export const withRuntime: { ) }) + +export const useScope: { + ( + deps: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect +} = 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), + ])), []) + const [scope, setScope] = React.useState(initialScope) + + React.useEffect(() => Runtime.runSync(runtime)( + Effect.if(isInitialRun, { + onTrue: () => Effect.as( + Ref.set(isInitialRun, false), + () => closeScope(scope, runtime, options), + ), + + onFalse: () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential).pipe( + Effect.tap(scope => Effect.sync(() => setScope(scope))), + Effect.map(scope => () => closeScope(scope, runtime, options)), + ), + }) + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) + + return scope +}) + +const closeScope = ( + scope: Scope.CloseableScope, + runtime: Runtime.Runtime, + options?: ScopeOptions, +) => { + switch (options?.finalizerExecutionMode ?? "sync") { + case "sync": + Runtime.runSync(runtime)(Scope.close(scope, Exit.void)) + break + case "fork": + Runtime.runFork(runtime)(Scope.close(scope, Exit.void)) + break + } +} + export const useOnMount: { ( f: () => Effect.Effect @@ -430,6 +483,108 @@ export const useOnChange: { deps: React.DependencyList, ) { const runtime = yield* Effect.runtime() - // biome-ignore lint/correctness/useExhaustiveDependencies: "f" is non-reactive + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(f())), deps) }) + +export const useReactEffect: { + ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect> +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, +) { + const runtime = yield* Effect.runtime>() + + React.useEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) +}) + +export const useReactLayoutEffect: { + ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, + ): Effect.Effect> +} = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: ScopeOptions, +) { + const runtime = yield* Effect.runtime>() + + React.useLayoutEffect(() => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => closeScope(scope, runtime, options) + ), + Runtime.runSync(runtime), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + ), deps) +}) + +export const useCallbackSync: { + ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => A, never, R> +} = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(f(...args)), deps) +}) + +export const useCallbackPromise: { + ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, + ): Effect.Effect<(...args: Args) => Promise, never, R> +} = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +) { + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = yield* Effect.runtime() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(f(...args)), deps) +}) + +export const useContext: { + ( + layer: Layer.Layer, + options?: ScopeOptions, + ): Effect.Effect, E, RIn> +} = Effect.fnUntraced(function* ( + layer: Layer.Layer, + options?: ScopeOptions, +) { + const scope = yield* useScope([layer], options) + + return yield* useOnChange(() => Effect.context().pipe( + Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), + Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), + Effect.andThen(runtime => runtime.runtimeEffect), + Effect.andThen(runtime => runtime.context), + Effect.provideService(Scope.Scope, scope), + ), [scope]) +}) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index e829a11..23731cd 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,10 +1,11 @@ import * as AsyncData from "@typed/async-data" -import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream, SubscriptionRef } from "effect" +import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, Ref, Schema, type Scope, Stream } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" -import * as Hooks from "./Hooks/index.js" +import * as Component from "./Component.js" import * as PropertyPath from "./PropertyPath.js" import * as Subscribable from "./Subscribable.js" +import * as SubscriptionRef from "./SubscriptionRef.js" import * as SubscriptionSubRef from "./SubscriptionSubRef.js" @@ -163,7 +164,7 @@ export namespace service { export const service = ( options: service.Options -): Effect.Effect, never, R | Scope.Scope> => Effect.tap( +): Effect.Effect, never, Scope.Scope | R> => Effect.tap( make(options), form => Effect.forkScoped(run(form)), ) @@ -220,24 +221,6 @@ extends Pipeable.Class() implements FormField { export const isFormField = (u: unknown): u is FormField => Predicate.hasProperty(u, FormFieldTypeId) -export namespace useForm { - export interface Options - extends make.Options {} -} - -export const useForm: { - ( - options: make.Options, - deps: React.DependencyList, - ): Effect.Effect, never, R> -} = Effect.fnUntraced(function* ( - options: make.Options, - deps: React.DependencyList, -) { - const form = yield* Hooks.useMemo(() => make(options), [options.debounce, ...deps]) - yield* Hooks.useFork(() => run(form), [form]) - return form -}) export const useSubmit = ( self: Form @@ -245,7 +228,7 @@ export const useSubmit = ( () => Promise>>, never, SR -> => Hooks.useCallbackPromise(() => submit(self), [self]) +> => Component.useCallbackPromise(() => submit(self), [self]) export const useField = >>( self: Form, @@ -271,33 +254,34 @@ export const useInput: { ( field: FormField, options?: useInput.Options, - ): Effect.Effect, NoSuchElementException> + ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField, 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)), + const internalValueRef = yield* Component.useOnChange(() => Effect.tap( + Effect.andThen(field.encodedValueRef, SubscriptionRef.make), + internalValueRef => Effect.forkScoped(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, + 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), ), - internalValue => Ref.set(field.encodedValueRef, internalValue), - ), - ], { concurrency: "unbounded" }), [field, internalValueRef, options?.debounce]) + ], { concurrency: "unbounded" })), + ), [field, options?.debounce]) + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { value, setValue } }) @@ -316,55 +300,56 @@ export const useOptionalInput: { ( field: FormField>, options: useOptionalInput.Options, - ): Effect.Effect, NoSuchElementException> + ): Effect.Effect, NoSuchElementException, Scope.Scope> } = Effect.fnUntraced(function* ( field: FormField>, options: useOptionalInput.Options, ) { - 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 [enabledRef, internalValueRef] = yield* Component.useOnChange(() => Effect.tap( + 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)]), + }), + ), - const [enabled, setEnabled] = yield* Hooks.useRefState(enabledRef) - const [value, setValue] = yield* Hooks.useRefState(internalValueRef) + ([enabledRef, internalValueRef]) => Effect.forkScoped(Effect.all([ + Stream.runForEach( + Stream.drop(field.encodedValueRef, 1), - 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), + ), + }), - upstreamEncodedValue => Effect.whenEffect( - Option.match(upstreamEncodedValue, { - onSome: v => Effect.andThen( - Ref.set(enabledRef, true), - Ref.set(internalValueRef, v), + Effect.andThen( + Effect.all([enabledRef, internalValueRef]), + ([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()), ), - 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, + 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()), ), - ([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()), - ), - ], { concurrency: "unbounded" }), [field, enabledRef, internalValueRef, options.debounce]) + ], { concurrency: "unbounded" })), + ), [field, options.debounce]) + const [enabled, setEnabled] = yield* SubscriptionRef.useSubscriptionRefState(enabledRef) + const [value, setValue] = yield* SubscriptionRef.useSubscriptionRefState(internalValueRef) return { enabled, setEnabled, value, setValue } }) diff --git a/packages/effect-fc/src/Subscribable.ts b/packages/effect-fc/src/Subscribable.ts index 9ebba36..e1d2d7f 100644 --- a/packages/effect-fc/src/Subscribable.ts +++ b/packages/effect-fc/src/Subscribable.ts @@ -1,17 +1,47 @@ -import { Effect, Stream, Subscribable } from "effect" +import { Effect, Equivalence, pipe, type Scope, Stream, Subscribable } from "effect" +import * as React from "react" +import * as Component from "./Component.js" -export const zipLatestAll = >>( - ...subscribables: T +export const zipLatestAll = []>( + ...elements: T ): Subscribable.Subscribable< [T[number]] extends [never] ? never : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, - [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _E : never, - [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? _R : never + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never > => Subscribable.make({ - get: Effect.all(subscribables.map(v => v.get)), - changes: Stream.zipLatestAll(...subscribables.map(v => v.changes)), + get: Effect.all(elements.map(v => v.get)), + changes: Stream.zipLatestAll(...elements.map(v => v.changes)), }) as any +export const useSubscribables: { + []>( + ...elements: T + ): Effect.Effect< + [T[number]] extends [never] + ? never + : { [K in keyof T]: T[K] extends Subscribable.Subscribable ? A : never }, + [T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? E : never, + ([T[number]] extends [never] ? never : T[number] extends Subscribable.Subscribable ? R : never) | Scope.Scope + > +} = Effect.fnUntraced(function* []>( + ...elements: T +) { + const [reactStateValue, setReactStateValue] = React.useState( + yield* Component.useOnMount(() => Effect.all(elements.map(v => v.get))) + ) + + yield* Component.useOnChange(() => Effect.forkScoped(pipe( + elements.map(ref => Stream.changesWith(ref.changes, Equivalence.strict())), + streams => Stream.zipLatestAll(...streams), + Stream.runForEach(v => + Effect.sync(() => setReactStateValue(v)) + ), + )), elements) + + return reactStateValue as any +}) + export * from "effect/Subscribable" diff --git a/packages/effect-fc/src/SubscriptionRef.ts b/packages/effect-fc/src/SubscriptionRef.ts new file mode 100644 index 0000000..c1afcc2 --- /dev/null +++ b/packages/effect-fc/src/SubscriptionRef.ts @@ -0,0 +1,43 @@ +import { Effect, Equivalence, type Scope, Stream, SubscriptionRef } from "effect" +import * as React from "react" +import * as Component from "./Component.js" +import * as SetStateAction from "./SetStateAction.js" + + +export const useSubscriptionRefState: { + ( + ref: SubscriptionRef.SubscriptionRef + ): Effect.Effect>], never, Scope.Scope> +} = Effect.fnUntraced(function* (ref: SubscriptionRef.SubscriptionRef) { + const [reactStateValue, setReactStateValue] = React.useState(yield* Component.useOnMount(() => ref)) + + yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach( + Stream.changesWith(ref.changes, Equivalence.strict()), + v => Effect.sync(() => setReactStateValue(v)), + )), [ref]) + + const setValue = yield* Component.useCallbackSync((setStateAction: React.SetStateAction) => + Effect.andThen( + SubscriptionRef.updateAndGet(ref, prevState => SetStateAction.value(setStateAction, prevState)), + v => setReactStateValue(v), + ), + [ref]) + + return [reactStateValue, setValue] +}) + +export const useSubscriptionRefFromState: { + (state: readonly [A, React.Dispatch>]): Effect.Effect, never, Scope.Scope> +} = Effect.fnUntraced(function*([value, setValue]) { + const ref = yield* Component.useOnMount(() => SubscriptionRef.make(value)) + + yield* Component.useOnChange(() => Effect.forkScoped(Stream.runForEach( + Stream.changesWith(ref.changes, Equivalence.strict()), + v => Effect.sync(() => setValue(v)), + )), [setValue]) + yield* Component.useReactEffect(() => SubscriptionRef.set(ref, value), [value]) + + return ref +}) + +export * from "effect/SubscriptionRef" diff --git a/packages/effect-fc/src/index.ts b/packages/effect-fc/src/index.ts index b2227e8..abdca26 100644 --- a/packages/effect-fc/src/index.ts +++ b/packages/effect-fc/src/index.ts @@ -7,4 +7,5 @@ export * as PropertyPath from "./PropertyPath.js" export * as ReactRuntime from "./ReactRuntime.js" export * as SetStateAction from "./SetStateAction.js" export * as Subscribable from "./Subscribable.js" +export * as SubscriptionRef from "./SubscriptionRef.js" export * as SubscriptionSubRef from "./SubscriptionSubRef.js" diff --git a/packages/example/src/domain/Todo.ts b/packages/example/src/domain/Todo.ts index f4fe24f..500faab 100644 --- a/packages/example/src/domain/Todo.ts +++ b/packages/example/src/domain/Todo.ts @@ -1,5 +1,5 @@ -import { assertEncodedJsonifiable } from "@/lib/schema" import { Schema } from "effect" +import { assertEncodedJsonifiable } from "@/lib/schema" export class Todo extends Schema.Class("Todo")({ diff --git a/packages/example/src/lib/input/TextAreaInput.tsx b/packages/example/src/lib/input/TextAreaInput.tsx deleted file mode 100644 index 03d4359..0000000 --- a/packages/example/src/lib/input/TextAreaInput.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** 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 * as React from "react" - - -export type TextAreaInputProps = Omit, "schema" | "equivalence"> & Omit - -export const TextAreaInput = (options: { - readonly schema: Schema.Schema - readonly equivalence?: Equivalence.Equivalence -}): Component.Component< - TextAreaInputProps, - React.JSX.Element, - ParseResult.ParseError, - R -> => Component.makeUntraced("TextFieldInput")(function*(props) { - const input = yield* useInput({ ...options, ...props }) - const issue = React.useMemo(() => input.error.pipe( - Option.map(ParseResult.ArrayFormatter.formatErrorSync), - Option.flatMap(Array.head), - ), [input.error]) - - return ( - -