diff --git a/packages/effect-fc-next/src/Component.new.ts b/packages/effect-fc-next/src/Component.new.ts new file mode 100644 index 0000000..a8f3d4a --- /dev/null +++ b/packages/effect-fc-next/src/Component.new.ts @@ -0,0 +1,385 @@ +/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ +import { + type Context, + type Duration, + Effect, + Exit, + Function, + identity, + Layer, + Pipeable, + Predicate, + Scope, + Tracer, +} from "effect" +import * as React from "react" + + +export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") +export type ComponentTypeId = typeof ComponentTypeId + +export interface Component

+extends ComponentPrototype, ComponentOptions { + new(_: never): Record + readonly [ComponentTypeId]: ComponentTypeId + readonly "~Props": P + readonly "~Success": A + readonly "~Error": E + readonly "~Context": R + readonly "~Function": F + readonly body: (props: P) => Effect.Effect +} + +export declare namespace Component { + export type Default

= Component> + export type Any = Component + export type Signature = (props: any) => React.ReactNode + export type DefaultSignature

= (props: P) => A + export type Props = T["~Props"] + export type Success = T["~Success"] + export type Error = T["~Error"] + export type Context = T["~Context"] + export type Function = T["~Function"] + export type AsComponent = Component, Success, Error, Context, Function> +} + +export interface ComponentOptions { + readonly displayName?: string + readonly nonReactiveTags: readonly Context.Key[] + readonly finalizerExecutionStrategy: "sequential" | "parallel" + readonly finalizerExecutionDebounce: Duration.Input +} + +export const defaultOptions: ComponentOptions = { + nonReactiveTags: [Tracer.ParentSpan], + finalizerExecutionStrategy: "sequential", + finalizerExecutionDebounce: "100 millis", +} + +export interface ComponentPrototype extends Pipeable.Pipeable { + readonly [ComponentTypeId]: ComponentTypeId + readonly use: Effect.Effect> +} + +type ComponentImpl = Component + +const makeFunctionComponent = ( + self: ComponentImpl, + contextRef: React.RefObject>, +): Component.Signature => { + if ("asFunctionComponent" in self && typeof self.asFunctionComponent === "function") { + return self.asFunctionComponent(contextRef) + } + const FunctionComponent = (props: {}) => Effect.runSyncWith(contextRef.current)( + Effect.flatMap( + useScope([], self), + scope => Effect.provideService(self.body(props), Scope.Scope, scope), + ), + ) + FunctionComponent.displayName = self.displayName ?? "Anonymous" + return "transformFunctionComponent" in self && typeof self.transformFunctionComponent === "function" + ? self.transformFunctionComponent(FunctionComponent) + : FunctionComponent +} + +const use = Effect.fnUntraced(function* (self: ComponentImpl) { + const context = yield* Effect.context() + const cached = componentCache.get(self) + if (cached !== undefined) { + cached.contextRef.current = context + return cached.component + } + const contextRef = { current: context } + const component = makeFunctionComponent(self, contextRef) + componentCache.set(self, { contextRef, component }) + return component +}) + +const componentCache = new WeakMap } + readonly component: Component.Signature +}>() + +export const ComponentPrototype = Object.freeze({ + [ComponentTypeId]: ComponentTypeId, + ...Pipeable.Prototype, + get use() { + return use(this as ComponentImpl) + }, +}) as unknown as ComponentPrototype + +export const isComponent = (u: unknown): u is Component.Any => Predicate.hasProperty(u, ComponentTypeId) + +type GeneratorBody

= ( + props: P, +) => Effect.fn.Return + +type EffectBody

= ( + props: P, +) => Effect.Effect + +export interface Make { +

( + body: GeneratorBody | EffectBody, + ...pipeables: readonly Function[] + ): Component.Default + (name: string, options?: Tracer.SpanOptionsNoTrace):

( + body: GeneratorBody | EffectBody, + ...pipeables: readonly Function[] + ) => Component.Default +} + +const component = ( + body: Function, + displayName: string | undefined, + traced: boolean, + pipeables: readonly Function[], +): Component.Any => Object.setPrototypeOf( + Object.assign(() => {}, defaultOptions, { + body: traced && displayName + ? Effect.fn(displayName)(body as never, ...pipeables as []) + : Effect.fnUntraced(body as never, ...pipeables as []), + displayName, + }), + ComponentPrototype, +) + +export const make: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { + if (typeof nameOrBody === "string") { + return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, true, pipeables) + } + return component(nameOrBody, undefined, true, args as readonly Function[]) +}) as Make + +export const makeUntraced: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { + if (typeof nameOrBody === "string") { + return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, false, pipeables) + } + return component(nameOrBody, undefined, false, args as readonly Function[]) +}) as Make + +export declare namespace withSignature { + export type Result = ( + & Omit> + & Component, Component.Success, Component.Error, Component.Context, F> + ) +} + +export const withSignature: { + (): (self: T) => withSignature.Result + (self: T): withSignature.Result +} = (self?: Component.Any): any => self === undefined ? identity : self + +export const withOptions: { + (options: Partial): (self: T) => T + (self: T, options: Partial): T +} = Function.dual(2, (self: T, options: Partial): T => Object.setPrototypeOf( + Object.assign(() => {}, self, options), + Object.getPrototypeOf(self), +)) + +export const withRuntime: { +

( + context: React.Context>, + ): (self: Component, F>) => F +

( + self: Component, F>, + context: React.Context>, + ): F +} = Function.dual(2,

( + self: Component, + context: React.Context>, +) => function WithRuntime(props: P) { + return React.createElement( + Effect.runSyncWith(React.useContext(context))(self.use) as React.FC

, + props, + ) +}) + +export declare namespace useScope { + export interface Options { + readonly finalizerExecutionStrategy?: "sequential" | "parallel" + readonly finalizerExecutionDebounce?: Duration.Input + } +} + +export const useScope = Effect.fnUntraced(function* ( + deps: React.DependencyList, + options?: useScope.Options, +): Effect.fn.Return { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + const scope = React.useMemo( + () => Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy), + // biome-ignore lint/correctness/useExhaustiveDependencies: caller controls scope lifetime + deps, + ) + + React.useEffect(() => { + const pending = scopeCleanupTimers.get(scope) + if (pending !== undefined) clearTimeout(pending) + return () => { + const timer = setTimeout(() => { + Effect.runSyncWith(contextRef.current)(Scope.close(scope, Exit.succeed(undefined))) + scopeCleanupTimers.delete(scope) + }, durationMillis(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce)) + scopeCleanupTimers.set(scope, timer) + } + }, [scope, options?.finalizerExecutionDebounce]) + + return scope +}) + +const scopeCleanupTimers = new WeakMap>() + +const durationMillis = (input: Duration.Input): number => { + if (typeof input === "number") return input + return Number(Effect.runSync(Effect.map(Effect.succeed(input), value => { + const match = typeof value === "string" ? /([\d.]+)\s*(millis|seconds?)/.exec(value) : undefined + if (!match) return 0 + return Number(match[1]) * (match[2].startsWith("second") ? 1_000 : 1) + }))) +} + +export const useOnMount = Effect.fnUntraced(function* ( + f: () => Effect.Effect, +): Effect.fn.Return { + const context = yield* Effect.context() + const id = React.useId() + let cached = mountCache.get(id) + if (cached === undefined) { + cached = { + effect: Effect.runSyncWith(context)(Effect.cached(f())), + } + mountCache.set(id, cached) + } + React.useEffect(() => { + if (cached?.cleanup !== undefined) clearTimeout(cached.cleanup) + const entry = cached + return () => { + entry.cleanup = setTimeout(() => mountCache.delete(id), 0) + } + }, [id, cached]) + return yield* cached.effect as Effect.Effect +}) + +const mountCache = new Map + cleanup?: ReturnType +}>() + +export declare namespace useOnChange { + export interface Options extends useScope.Options {} +} + +export const useOnChange = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps: React.DependencyList, + options?: useOnChange.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + const scope = yield* useScope(deps, options) + const cached = + // biome-ignore lint/correctness/useExhaustiveDependencies: scope tracks the caller-provided dependency list + React.useMemo( + () => Effect.runSyncWith(context)(Effect.cached(Effect.provideService(f(), Scope.Scope, scope))), + [scope], + ) + return yield* cached +}) + +export declare namespace useReactEffect { + export interface Options { + readonly finalizerExecutionMode?: "sync" | "fork" + readonly finalizerExecutionStrategy?: "sequential" | "parallel" + } +} + +const runReactEffect = ( + context: Context.Context>, + f: () => Effect.Effect, + options?: useReactEffect.Options, +): (() => void) => { + const scope = Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy) + Effect.runSyncWith(context)(Effect.exit(Effect.provideService(f(), Scope.Scope, scope))) + return () => { + const close = Scope.close(scope, Exit.succeed(undefined)) + if ((options?.finalizerExecutionMode ?? "fork") === "sync") Effect.runSyncWith(context)(close) + else Effect.runForkWith(context)(close) + } +} + +export const useReactEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactEffect.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + React.useEffect(() => runReactEffect(context, f, options), deps) +}) + +export declare namespace useReactLayoutEffect { + export interface Options extends useReactEffect.Options {} +} + +export const useReactLayoutEffect = Effect.fnUntraced(function* ( + f: () => Effect.Effect, + deps?: React.DependencyList, + options?: useReactLayoutEffect.Options, +): Effect.fn.Return> { + const context = yield* Effect.context>() + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + React.useLayoutEffect(() => runReactEffect(context, f, options), deps) +}) + +export const useRunSync = (): Effect.Effect< + (effect: Effect.Effect) => A, + never, + R +> => Effect.map(Effect.context(), Effect.runSyncWith) + +export const useRunPromise = (): Effect.Effect< + (effect: Effect.Effect) => Promise, + never, + R +> => Effect.map(Effect.context(), Effect.runPromiseWith) + +export const useCallbackSync = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => A, never, R> { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + return React.useCallback((...args: Args) => Effect.runSyncWith(contextRef.current)(f(...args)), deps) +}) + +export const useCallbackPromise = Effect.fnUntraced(function* ( + f: (...args: Args) => Effect.Effect, + deps: React.DependencyList, +): Effect.fn.Return<(...args: Args) => Promise, never, R> { + const context = yield* Effect.context() + const contextRef = React.useRef(context) + contextRef.current = context + // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps) +}) + +export declare namespace useContext { + export interface Options extends useOnChange.Options {} +} + +export const useContextFromLayer = ( + layer: Layer.Layer, + options?: useContext.Options, +): Effect.Effect, E, RIn | Scope.Scope> => useOnChange( + () => Effect.flatMap( + Effect.context(), + context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), + ), + [layer], + options, +) diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index 6bf9d93..ed24870 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -1,23 +1,15 @@ /** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ -import { - Context, - type Duration, - Effect, - Exit, - Function, - identity, - Layer, - Pipeable, - Predicate, - Scope, - Tracer, -} from "effect" +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, identity, Layer, Option, Pipeable, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect" import * as React from "react" export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") export type ComponentTypeId = typeof ComponentTypeId +/** + * Represents an Effect-based React Component that integrates the Effect system with React. + */ export interface Component

extends ComponentPrototype, ComponentOptions { new(_: never): Record @@ -27,137 +19,507 @@ extends ComponentPrototype, ComponentOptions { readonly "~Error": E readonly "~Context": R readonly "~Function": F + readonly body: (props: P) => Effect.Effect } export declare namespace Component { export type Default

= Component> export type Any = Component + export type Signature = (props: any) => React.ReactNode export type DefaultSignature

= (props: P) => A - export type Props = T["~Props"] - export type Success = T["~Success"] - export type Error = T["~Error"] - export type Context = T["~Context"] - export type Function = T["~Function"] + + export type Props = [T] extends [Component] ? P : never + export type Success = [T] extends [Component] ? A : never + export type Error = [T] extends [Component] ? E : never + export type Context = [T] extends [Component] ? R : never + export type Function = [T] extends [Component] ? F : never + export type AsComponent = Component, Success, Error, Context, Function> } + +export interface ComponentImpl

+extends Component, ComponentImplPrototype {} + +export interface ComponentImplPrototype { + readonly use: Effect.Effect> + + asFunctionComponent(contextRef: React.Ref>>): F + setFunctionComponentName(f: F): void + transformFunctionComponent(f: F): F +} + +export const ComponentImplPrototype: ComponentImplPrototype = Object.freeze({ + get use() { return use(this) }, + + asFunctionComponent

( + this: ComponentImpl, + contextRef: React.RefObject>>, + ) { + return (props: P) => Effect.runSyncWith(contextRef.current)( + Effect.andThen( + useScope([], this), + scope => Effect.provideService(this.body(props), Scope.Scope, scope), + ) + ) + }, + + setFunctionComponentName

( + this: ComponentImpl, + f: React.FC

, + ) { + f.displayName = this.displayName ?? "Anonymous" + }, + + transformFunctionComponent: identity, +} as const) + +const use = Effect.fnUntraced(function*

( + self: ComponentImpl +) { + // biome-ignore lint/style/noNonNullAssertion: React ref initialization + const contextRef = React.useRef>>(null!) + contextRef.current = yield* Effect.context>() + + return yield* React.useState(() => Effect.runSyncWith(contextRef.current)(Effect.cachedFunction( + (_services: readonly any[]) => Effect.sync(() => { + const f = self.asFunctionComponent(contextRef) + self.setFunctionComponentName(f) + return self.transformFunctionComponent(f) + }), + Equivalence.array(Equivalence.strictEqual()), + )))[0](Array.from( + Context.omit(...self.nonReactiveTags)(contextRef.current).mapUnsafe.values() + )) +}) + + +export interface ComponentPrototype +extends Pipeable.Pipeable { + readonly [ComponentTypeId]: ComponentTypeId + readonly use: Effect.Effect> +} + +export const ComponentPrototype: ComponentPrototype = Object.freeze( + Object.defineProperties( + { + [ComponentTypeId]: ComponentTypeId, + ...Pipeable.Prototype, + }, + Object.getOwnPropertyDescriptors(ComponentImplPrototype), + ) as ComponentPrototype +) + + export interface ComponentOptions { + /** + * Custom display name for the component in React DevTools and debugging utilities. + */ readonly displayName?: string + + /** + * Context tags that should not trigger component remount when their values change. + * + * @default [Tracer.ParentSpan] + */ readonly nonReactiveTags: readonly Context.Key[] - readonly finalizerExecutionStrategy: "sequential" | "parallel" + + /** + * Specifies the execution strategy for finalizers when the component unmounts or its scope closes. + * Determines whether finalizers execute sequentially or in parallel. + * + * @default ExecutionStrategy.sequential + */ + readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy + + /** + * Debounce duration before executing finalizers after component unmount. + * Prevents unnecessary cleanup work during rapid remount/unmount cycles, + * which is common in development and certain UI patterns. + * + * @default "100 millis" + */ readonly finalizerExecutionDebounce: Duration.Input } export const defaultOptions: ComponentOptions = { nonReactiveTags: [Tracer.ParentSpan], - finalizerExecutionStrategy: "sequential", + finalizerExecutionStrategy: ExecutionStrategy.sequential, finalizerExecutionDebounce: "100 millis", } -export interface ComponentPrototype extends Pipeable.Pipeable { - readonly [ComponentTypeId]: ComponentTypeId - readonly use: Effect.Effect> -} -type ComponentImpl = Component +export const isComponent = (u: unknown): u is Component.Default<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId) -const makeFunctionComponent = ( - self: ComponentImpl, - contextRef: React.RefObject>, -): Component.Signature => { - if ("asFunctionComponent" in self && typeof self.asFunctionComponent === "function") { - return self.asFunctionComponent(contextRef) +export declare namespace make { + export type Gen = { + >, A extends React.ReactNode, P extends {} = {}>( + body: (props: P) => Generator + ): Component.Default< + P, A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + > + >, A, B extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H, I extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + >, A, B, C, D, E, F, G, H, I, J extends Effect.Effect, P extends {} = {}>( + body: (props: P) => Generator, + a: ( + _: Effect.Effect< + A, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? E : never, + [Eff] extends [never] ? never : [Eff] extends [Utils.YieldWrap>] ? R : never + >, + props: NoInfer

, + ) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => J, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> } - const FunctionComponent = (props: {}) => Effect.runSyncWith(contextRef.current)( - Effect.flatMap( - useScope([], self), - scope => Effect.provideService(self.body(props), Scope.Scope, scope), - ), - ) - FunctionComponent.displayName = self.displayName ?? "Anonymous" - return "transformFunctionComponent" in self && typeof self.transformFunctionComponent === "function" - ? self.transformFunctionComponent(FunctionComponent) - : FunctionComponent -} -const use = Effect.fnUntraced(function* (self: ComponentImpl) { - const context = yield* Effect.context() - const cached = componentCache.get(self) - if (cached !== undefined) { - cached.contextRef.current = context - return cached.component + export type NonGen = { + , P extends {} = {}>( + body: (props: P) => Eff + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, H, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> + , A, B, C, D, E, F, G, H, I, P extends {} = {}>( + body: (props: P) => A, + a: (_: A, props: NoInfer

) => B, + b: (_: B, props: NoInfer

) => C, + c: (_: C, props: NoInfer

) => D, + d: (_: D, props: NoInfer

) => E, + e: (_: E, props: NoInfer

) => F, + f: (_: F, props: NoInfer

) => G, + g: (_: G, props: NoInfer

) => H, + h: (_: H, props: NoInfer

) => I, + i: (_: I, props: NoInfer

) => Eff, + ): Component.Default>, Effect.Effect.Error, Effect.Effect.Context> } - const contextRef = { current: context } - const component = makeFunctionComponent(self, contextRef) - componentCache.set(self, { contextRef, component }) - return component -}) - -const componentCache = new WeakMap } - readonly component: Component.Signature -}>() - -export const ComponentPrototype = Object.freeze({ - [ComponentTypeId]: ComponentTypeId, - ...Pipeable.Prototype, - get use() { - return use(this as ComponentImpl) - }, -}) as unknown as ComponentPrototype - -export const isComponent = (u: unknown): u is Component.Any => Predicate.hasProperty(u, ComponentTypeId) - -type GeneratorBody

= ( - props: P, -) => Effect.fn.Return - -type EffectBody

= ( - props: P, -) => Effect.Effect - -export interface Make { -

( - body: GeneratorBody | EffectBody, - ...pipeables: readonly Function[] - ): Component.Default - (name: string, options?: Tracer.SpanOptionsNoTrace):

( - body: GeneratorBody | EffectBody, - ...pipeables: readonly Function[] - ) => Component.Default } -const component = ( - body: Function, - displayName: string | undefined, - traced: boolean, - pipeables: readonly Function[], -): Component.Any => Object.setPrototypeOf( - Object.assign(function() {}, defaultOptions, { - body: traced && displayName - ? Effect.fn(displayName)(body as never, ...pipeables as []) - : Effect.fnUntraced(body as never, ...pipeables as []), - displayName, - }), - ComponentPrototype, +/** + * Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`. + * + * This is the **recommended** approach for defining Effect-FC components. It provides comprehensive + * support for multiple component definition patterns: + * + * - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations + * - **Direct Effect return**: For simple components that return an Effect directly + * - **Chained transformation functions**: Enables Effect.fn-style pipelines for composable transformations + * - **Automatic tracing**: Optional tracing span creation with automatic `displayName` assignment + * + * When a `spanName` string is provided, the following occurs automatically: + * 1. A distributed tracing span is created with the specified name + * 2. The resulting React component receives `displayName = spanName` for DevTools visibility + * + * @example + * ```tsx + * const MyComponent = Component.make("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return

{value}
+ * }) + * ``` + * + * @example As an opaque type using class syntax + * ```tsx + * class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Without name + * ```tsx + * class MyComponent extends Component.make(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.make("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} + * ``` + */ +export const make: ( + & make.Gen + & make.NonGen + & (( + spanName: string, + spanOptions?: Tracer.SpanOptions, + ) => make.Gen & make.NonGen) +) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => { + if (typeof spanNameOrBody !== "string") { + return Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fn(spanNameOrBody as any, ...pipeables), + }), + ComponentPrototype, + ) + } + else { + const spanOptions = pipeables[0] + return (body: any, ...pipeables: any[]) => Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []), + displayName: spanNameOrBody, + }), + ComponentPrototype, + ) + } +} + +/** + * Creates an Effect-FC Component without automatic distributed tracing. + * + * This function provides the same API surface as `make`, but does not create automatic tracing spans. + * It follows the exact same overload structure as `Effect.fnUntraced`. + * + * Use this variant when you need: + * - Full manual control over tracing instrumentation + * - To reduce tracing overhead in deeply nested component hierarchies + * - To avoid span noise in performance-sensitive applications + * + * When a `spanName` string is provided, it is used **exclusively** as the React component's + * `displayName` for DevTools identification. No tracing span is created. + * + * @example + * ```tsx + * const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) + * ``` + * + * @example As an opaque type using class syntax + * ```tsx + * class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Without name + * ```tsx + * class MyComponent extends Component.makeUntraced(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.makeUntraced("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} + * ``` + */ +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 []), + }), + ComponentPrototype, + ) + : (body: any, ...pipeables: any[]) => Object.setPrototypeOf( + Object.assign(function() {}, defaultOptions, { + body: Effect.fnUntraced(body, ...pipeables as []), + displayName: spanNameOrBody, + }), + ComponentPrototype, + ) ) -export const make: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { - if (typeof nameOrBody === "string") { - return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, true, pipeables) - } - return component(nameOrBody, undefined, true, args as readonly Function[]) -}) as Make - -export const makeUntraced: Make = ((nameOrBody: string | Function, ...args: readonly unknown[]) => { - if (typeof nameOrBody === "string") { - return (body: Function, ...pipeables: readonly Function[]) => component(body, nameOrBody, false, pipeables) - } - return component(nameOrBody, undefined, false, args as readonly Function[]) -}) as Make - export declare namespace withSignature { export type Result = ( & Omit> @@ -166,113 +528,266 @@ export declare namespace withSignature { } export const withSignature: { - (): (self: T) => withSignature.Result - (self: T): withSignature.Result -} = (self?: Component.Any): any => self === undefined ? identity : self + (): ( + self: T + ) => withSignature.Result + ( + self: T + ): withSignature.Result +} = (self?: Component.Any): any => self === undefined + ? identity + : self +/** + * Creates a new component with modified configuration options while preserving all original behavior. + * + * This function allows you to customize component-level options such as finalizer execution strategy + * and debounce timing. + * + * @example + * ```tsx + * const MyComponentWithCustomOptions = MyComponent.pipe( + * Component.withOptions({ + * finalizerExecutionStrategy: ExecutionStrategy.parallel, + * finalizerExecutionDebounce: "50 millis", + * }) + * ) + * ``` + */ export const withOptions: { - (options: Partial): (self: T) => T - (self: T, options: Partial): T -} = Function.dual(2, (self: T, options: Partial): T => Object.setPrototypeOf( + ( + options: Partial + ): (self: T) => T + ( + self: T, + options: Partial, + ): T +} = Function.dual(2, ( + self: T, + options: Partial, +): T => Object.setPrototypeOf( Object.assign(function() {}, self, options), Object.getPrototypeOf(self), )) +/** + * Wraps an Effect-FC Component and converts it into a standard React function component, + * serving as an **entrypoint** into an Effect-FC component hierarchy. + * + * This is how Effect-FC components are integrated with the broader React ecosystem, + * particularly when: + * - Using client-side routers (TanStack Router, React Router, etc.) + * - Implementing lazy-loaded or code-split routes + * - Connecting to third-party libraries expecting standard React components + * - Creating component boundaries between Effect-FC and non-Effect-FC code + * + * The Effect runtime is obtained from the provided React Context. + * + * @param self - The Effect-FC Component to be rendered as a standard React component + * @param context - React Context providing the Effect Runtime for this component tree. + * Create this using the `ReactRuntime` module. + * + * @example Integration with TanStack Router + * ```tsx + * // Application root + * export const runtime = ReactRuntime.make(Layer.empty) + * + * function App() { + * return ( + * + * + * + * ) + * } + * + * // Route definition + * export const Route = createFileRoute("/")({ + * component: Component.withRuntime(HomePage, runtime.context) + * }) + * ``` + * + */ export const withRuntime: {

( - context: React.Context>, + context: React.Context>, ): (self: Component, F>) => F

( self: Component, F>, - context: React.Context>, + context: React.Context>, ): F } = Function.dual(2,

( - self: Component, - context: React.Context>, + self: Component, + context: React.Context>, ) => function WithRuntime(props: P) { return React.createElement( - Effect.runSyncWith(React.useContext(context))(self.use) as React.FC

, + Runtime.runSync(React.useContext(context))(self.use) as React.FC

, props, ) }) + +/** + * Internal Effect service that maintains a registry of scopes associated with React component instances. + * + * This service is used internally by the `useScope` hook to manage the lifecycle of component scopes, + * including tracking active scopes and coordinating their cleanup when components unmount or dependencies change. + */ +export class ScopeMap extends Context.Service> +}>()( + "@effect-fc/Component/ScopeMap" +) { + static readonly layer = Layer.effect(ScopeMap, Effect.map( + Ref.make(HashMap.empty()), + ref => ({ ref }), + )) +} + +export declare namespace ScopeMap { + export interface Entry { + readonly scope: Scope.Closeable + readonly closeFiber: Option.Option> + } +} + + export declare namespace useScope { export interface Options { - readonly finalizerExecutionStrategy?: "sequential" | "parallel" + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy readonly finalizerExecutionDebounce?: Duration.Input } } -export const useScope = Effect.fnUntraced(function* ( +/** + * Effect hook that creates and manages a `Scope` for the current component instance. + * + * This hook establishes a new scope that is automatically closed when: + * - The component unmounts + * - The dependency array `deps` changes + * + * The scope provides a resource management boundary for any Effects executed within the component, + * ensuring proper cleanup of resources and execution of finalizers. + * + * @param deps - Dependency array following React.useEffect semantics. The scope is recreated + * whenever any dependency changes. + * @param options - Configuration for finalizer execution behavior, including execution strategy + * and debounce timing. + * + * @returns An Effect that produces a `Scope` for resource management +*/ +export const useScope = Effect.fnUntraced(function*( deps: React.DependencyList, options?: useScope.Options, ): Effect.fn.Return { - const context = yield* Effect.context() - const contextRef = React.useRef(context) - contextRef.current = context - const scope = React.useMemo( - () => Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy), - // biome-ignore lint/correctness/useExhaustiveDependencies: caller controls scope lifetime - deps, - ) + // biome-ignore lint/style/noNonNullAssertion: context initialization + const contextRef = React.useRef>(null!) + contextRef.current = yield* Effect.context() - React.useEffect(() => { - const pending = scopeCleanupTimers.get(scope) - if (pending !== undefined) clearTimeout(pending) - return () => { - const timer = setTimeout(() => { - Effect.runSyncWith(contextRef.current)(Scope.close(scope, Exit.succeed(undefined))) - scopeCleanupTimers.delete(scope) - }, durationMillis(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce)) - scopeCleanupTimers.set(scope, timer) - } - }, [scope, options?.finalizerExecutionDebounce]) + const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe( + Effect.bind("scopeMapRef", () => Effect.map( + ScopeMap as unknown as Effect.Effect, + scopeMap => scopeMap.ref, + )), + Effect.let("key", () => ({})), + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), + Effect.tap(({ scopeMapRef, key, scope }) => + Ref.update(scopeMapRef, HashMap.set(key, { + scope, + closeFiber: Option.none(), + })) + ), + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList + )), deps) + + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" + React.useEffect(() => Effect.runSyncWith(contextRef.current)( + (ScopeMap as unknown as Effect.Effect).pipe( + Effect.map(scopeMap => scopeMap.ref), + Effect.tap(ref => Ref.get(ref).pipe( + Effect.flatMap(map => Effect.fromOption(HashMap.get(map, key))), + Effect.flatMap(entry => Option.match(entry.closeFiber, { + onSome: fiber => Effect.forkDetach(Fiber.interrupt(fiber)), + onNone: () => Effect.void, + })), + )), + Effect.map(ref => + () => Effect.runSyncWith(contextRef.current)(Effect.flatMap( + Effect.sleep(options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce).pipe( + Effect.andThen(Scope.close(scope, Exit.void)), + Effect.onExit(() => Ref.update(ref, HashMap.remove(key))), + Effect.forkDetach, + ), + fiber => Ref.update(ref, HashMap.set(key, { + scope, + closeFiber: Option.some(fiber), + })), + )) + ), + ) + ), [key]) return scope }) -const scopeCleanupTimers = new WeakMap>() - -const durationMillis = (input: Duration.Input): number => { - if (typeof input === "number") return input - return Number(Effect.runSync(Effect.map(Effect.succeed(input), value => { - const match = typeof value === "string" ? /([\d.]+)\s*(millis|seconds?)/.exec(value) : undefined - if (!match) return 0 - return Number(match[1]) * (match[2].startsWith("second") ? 1_000 : 1) - }))) -} - +/** + * Effect hook that executes an Effect once when the component mounts and caches the result. + * + * This hook is useful for one-time initialization logic that should not be re-executed + * when the component re-renders. The Effect is executed exactly once during the component's + * initial mount, and the cached result is returned on all subsequent renders. + * + * @param f - A function that returns the Effect to execute on mount + * + * @returns An Effect that produces the cached result of the Effect + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const initialData = yield* Component.useOnMount(() => getData) + * return

{initialData}
+ * }) + * ``` + */ export const useOnMount = Effect.fnUntraced(function* ( - f: () => Effect.Effect, + f: () => Effect.Effect ): Effect.fn.Return { const context = yield* Effect.context() - const id = React.useId() - let cached = mountCache.get(id) - if (cached === undefined) { - cached = { - effect: Effect.runSyncWith(context)(Effect.cached(f())), - } - mountCache.set(id, cached) - } - React.useEffect(() => { - if (cached?.cleanup !== undefined) clearTimeout(cached.cleanup) - const entry = cached - return () => { - entry.cleanup = setTimeout(() => mountCache.delete(id), 0) - } - }, [id, cached]) - return yield* cached.effect as Effect.Effect + return yield* React.useState(() => Effect.runSyncWith(context)(Effect.cached(f())))[0] }) -const mountCache = new Map - cleanup?: ReturnType -}>() - export declare namespace useOnChange { export interface Options extends useScope.Options {} } +/** + * Effect hook that executes an Effect whenever dependencies change and caches the result. + * + * This hook combines the dependency-tracking behavior of React.useEffect with Effect caching. + * The Effect is re-executed whenever any dependency in the `deps` array changes, and the result + * is cached until the next dependency change. + * + * A dedicated scope is created for each dependency change, ensuring proper resource cleanup: + * - The scope closes when dependencies change + * - The scope closes when the component unmounts + * - All finalizers are executed according to the configured execution strategy + * + * @param f - A function that returns the Effect to execute + * @param deps - Dependency array following React.useEffect semantics + * @param options - Configuration for scope and finalizer behavior + * + * @returns An Effect that produces the cached result of the Effect + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { userId: string }) { + * const userData = yield* Component.useOnChange( + * getUser(props.userId), + * [props.userId], + * ) + * return
{userData.name}
+ * }) + * ``` + */ export const useOnChange = Effect.fnUntraced(function* ( f: () => Effect.Effect, deps: React.DependencyList, @@ -280,91 +795,264 @@ export const useOnChange = Effect.fnUntraced(function* ( ): Effect.fn.Return> { const context = yield* Effect.context>() const scope = yield* useScope(deps, options) - const cached = - // biome-ignore lint/correctness/useExhaustiveDependencies: scope tracks the caller-provided dependency list - React.useMemo( - () => Effect.runSyncWith(context)(Effect.cached(Effect.provideService(f(), Scope.Scope, scope))), - [scope], - ) - return yield* cached + + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "scope" + return yield* React.useMemo(() => Effect.runSyncWith(context)( + Effect.cached(Effect.provideService(f(), Scope.Scope, scope)) + ), [scope]) }) export declare namespace useReactEffect { export interface Options { readonly finalizerExecutionMode?: "sync" | "fork" - readonly finalizerExecutionStrategy?: "sequential" | "parallel" - } -} - -const runReactEffect = ( - context: Context.Context>, - f: () => Effect.Effect, - options?: useReactEffect.Options, -): (() => void) => { - const scope = Scope.makeUnsafe(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy) - Effect.runSyncWith(context)(Effect.exit(Effect.provideService(f(), Scope.Scope, scope))) - return () => { - const close = Scope.close(scope, Exit.succeed(undefined)) - if ((options?.finalizerExecutionMode ?? "fork") === "sync") Effect.runSyncWith(context)(close) - else Effect.runForkWith(context)(close) + readonly finalizerExecutionStrategy?: ExecutionStrategy.ExecutionStrategy } } +/** + * Effect hook that provides Effect-based semantics for React.useEffect. + * + * This hook bridges React's useEffect with the Effect system, allowing you to use Effects + * for React side effects while maintaining React's dependency tracking and lifecycle semantics. + * + * Unlike React.useEffect which uses imperative cleanup functions, this hook leverages the + * Effect Scope API for resource management. Cleanup logic is expressed declaratively through + * finalizers registered with the scope, providing better composability and error handling. + * + * @param f - A function that returns an Effect to execute as a side effect + * @param deps - Optional dependency array following React.useEffect semantics. + * If omitted, the effect runs after every render. + * @param options - Configuration for finalizer execution mode (sync or fork) and strategy + * + * @returns An Effect that produces void + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { id: string }) { + * yield* Component.useReactEffect( + * () => getNotificationStreamForUser(props.id).pipe( + * Stream.unwrap, + * Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`), + * Effect.forkScoped, + * ), + * [props.id], + * ) + * return
Subscribed to notifications for {props.id}
+ * }) + * ``` + */ export const useReactEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, deps?: React.DependencyList, options?: useReactEffect.Options, ): Effect.fn.Return> { const context = yield* Effect.context>() - // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList React.useEffect(() => runReactEffect(context, f, options), deps) }) +const runReactEffect = ( + context: Context.Context>, + f: () => Effect.Effect, + options?: useReactEffect.Options, +) => Effect.Do.pipe( + Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), + Effect.bind("exit", ({ scope }) => Effect.exit(Effect.provideService(f(), Scope.Scope, scope))), + Effect.map(({ scope }) => + () => { + switch (options?.finalizerExecutionMode ?? "fork") { + case "sync": + Effect.runSyncWith(context)(Scope.close(scope, Exit.void)) + break + case "fork": + Effect.runForkWith(context)(Scope.close(scope, Exit.void)) + break + } + } + ), + Effect.runSyncWith(context), +) + export declare namespace useReactLayoutEffect { export interface Options extends useReactEffect.Options {} } +/** + * Effect hook that provides Effect-based semantics for React.useLayoutEffect. + * + * This hook is identical to `useReactEffect` but executes synchronously after DOM mutations + * but before the browser paints, following React.useLayoutEffect semantics. + * + * Use this hook when you need to: + * - Measure DOM elements (e.g., for layout calculations) + * - Synchronously update state based on DOM measurements + * - Avoid visual flicker from asynchronous updates + * + * Like `useReactEffect`, cleanup logic is handled through the Effect Scope API rather than + * imperative cleanup functions, providing declarative and composable resource management. + * + * @param f - A function that returns an Effect to execute as a layout side effect + * @param deps - Optional dependency array following React.useLayoutEffect semantics. + * If omitted, the effect runs after every render. + * @param options - Configuration for finalizer execution mode (sync or fork) and strategy + * + * @returns An Effect that produces void + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const ref = React.useRef(null) + * yield* Component.useReactLayoutEffect( + * () => Effect.gen(function* () { + * const element = ref.current + * if (element) { + * const rect = element.getBoundingClientRect() + * yield* Console.log(`Element dimensions: ${ rect.width }x${ rect.height }`) + * } + * }), + * [], + * ) + * return
Content
+ * }) + * ``` + */ export const useReactLayoutEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, deps?: React.DependencyList, options?: useReactLayoutEffect.Options, ): Effect.fn.Return> { const context = yield* Effect.context>() - // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList React.useLayoutEffect(() => runReactEffect(context, f, options), deps) }) +/** + * Effect hook that provides a synchronous function to execute Effects within the current runtime context. + * + * This hook returns a function that can execute Effects synchronously, blocking until completion. + * Use this when you need to run Effects from non-Effect code (e.g., event handlers, callbacks) + * within a component. + * + * @returns An Effect that produces a function capable of synchronously executing Effects + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const runSync = yield* Component.useRunSync() // Specify required services + * const runSync = yield* Component.useRunSync() // Or no service requirements + * + * return + * }) + * ``` + */ export const useRunSync = (): Effect.Effect< - (effect: Effect.Effect) => A, + (effect: Effect.Effect) => A, never, - R -> => Effect.map(Effect.context(), Effect.runSyncWith) + Scope.Scope | R +> => Effect.map(Effect.context(), Effect.runSyncWith) +/** + * Effect hook that provides an asynchronous function to execute Effects within the current runtime context. + * + * This hook returns a function that executes Effects asynchronously, returning a Promise that resolves + * with the Effect's result. Use this when you need to run Effects from non-Effect code (e.g., event handlers, + * async callbacks) and want to handle the result asynchronously. + * + * @returns An Effect that produces a function capable of asynchronously executing Effects + * + * @example + * ```tsx + * const MyComponent = Component.make(function*() { + * const runPromise = yield* Component.useRunPromise() // Specify required services + * const runPromise = yield* Component.useRunPromise() // Or no service requirements + * + * return + * }) + * ``` + */ export const useRunPromise = (): Effect.Effect< - (effect: Effect.Effect) => Promise
, + (effect: Effect.Effect) => Promise, never, - R -> => Effect.map(Effect.context(), Effect.runPromiseWith) + Scope.Scope | R +> => Effect.map(Effect.context(), Effect.runPromiseWith) +/** + * Effect hook that memoizes a function that returns an Effect, providing synchronous execution. + * + * This hook wraps a function that returns an Effect and returns a memoized version that: + * - Executes the Effect synchronously when called + * - Is memoized based on the provided dependency array + * - Maintains referential equality across renders when dependencies don't change + * + * Use this to create stable callback references for event handlers and other scenarios + * where you need to execute Effects synchronously from non-Effect code. + * + * @param f - A function that accepts arguments and returns an Effect + * @param deps - Dependency array. The memoized function is recreated when dependencies change. + * + * @returns An Effect that produces a memoized function with the same signature as `f` + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) { + * const handleSave = yield* Component.useCallbackSync( + * (data: Data) => Effect.sync(() => props.onSave(data)), + * [props.onSave], + * ) + * + * return + * }) + * ``` + */ export const useCallbackSync = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, deps: React.DependencyList, ): Effect.fn.Return<(...args: Args) => A, never, R> { - const context = yield* Effect.context() - const contextRef = React.useRef(context) - contextRef.current = context - // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + // biome-ignore lint/style/noNonNullAssertion: context initialization + const contextRef = React.useRef>(null!) + contextRef.current = yield* Effect.context() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList return React.useCallback((...args: Args) => Effect.runSyncWith(contextRef.current)(f(...args)), deps) }) +/** + * Effect hook that memoizes a function that returns an Effect, providing asynchronous execution. + * + * This hook wraps a function that returns an Effect and returns a memoized version that: + * - Executes the Effect asynchronously when called, returning a Promise + * - Is memoized based on the provided dependency array + * - Maintains referential equality across renders when dependencies don't change + * + * Use this to create stable callback references for async event handlers and other scenarios + * where you need to execute Effects asynchronously from non-Effect code. + * + * @param f - A function that accepts arguments and returns an Effect + * @param deps - Dependency array. The memoized function is recreated when dependencies change. + * + * @returns An Effect that produces a memoized function that returns a Promise + * + * @example + * ```tsx + * const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) { + * const handleSave = yield* Component.useCallbackPromise( + * (data: Data) => Effect.promise(() => props.onSave(data)), + * [props.onSave], + * ) + * + * return + * }) + * ``` + */ export const useCallbackPromise = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, deps: React.DependencyList, ): Effect.fn.Return<(...args: Args) => Promise, never, R> { - const context = yield* Effect.context() - const contextRef = React.useRef(context) - contextRef.current = context - // biome-ignore lint/correctness/useExhaustiveDependencies: caller supplies React dependencies + // biome-ignore lint/style/noNonNullAssertion: context initialization + const contextRef = React.useRef>(null!) + contextRef.current = yield* Effect.context() + + // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList return React.useCallback((...args: Args) => Effect.runPromiseWith(contextRef.current)(f(...args)), deps) }) @@ -372,14 +1060,71 @@ export declare namespace useContext { export interface Options extends useOnChange.Options {} } +/** + * Effect hook that constructs an Effect Layer and returns the resulting context. + * + * This hook creates a managed runtime from the provided layer and returns the context it produces. + * The layer is reconstructed whenever its value changes, so ensure the layer reference is stable + * (typically by memoizing it or defining it outside the component). + * + * The hook automatically manages the layer's lifecycle: + * - The layer is built when the component mounts or when the layer reference changes + * - Resources are properly released when the component unmounts or dependencies change + * - Finalizers are executed according to the configured execution strategy + * + * @param layer - The Effect Layer to construct. Should be a stable reference to avoid unnecessary + * reconstruction. Consider memoizing with React.useMemo if defined inline. + * @param options - Configuration for scope and finalizer behavior + * + * @returns An Effect that produces the context created by the layer + * + * @throws If the layer contains asynchronous effects, the component must be wrapped with `Async.async` + * + * @example + * ```tsx + * const MyLayer = Layer.succeed(MyService, new MyServiceImpl()) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContextFromLayer(MyLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }) + * ``` + * + * @example With memoized layer + * ```tsx + * const MyComponent = Component.make(function*(props: { id: string })) { + * const context = yield* Component.useContextFromLayer( + * React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id]) + * ) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }) + * ``` + * + * @example With async layer + * ```tsx + * const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContextFromLayer(MyAsyncLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return + * }).pipe( + * Async.async // Required to handle async layer effects + * ) + */ export const useContextFromLayer = ( layer: Layer.Layer, options?: useContext.Options, -): Effect.Effect, E, RIn | Scope.Scope> => useOnChange( - () => Effect.flatMap( - Effect.context(), - context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), - ), - [layer], - options, -) +): Effect.Effect, E, RIn | Scope.Scope> => useOnChange(() => Effect.flatMap( + Effect.context(), + context => Layer.build(Layer.provide(layer, Layer.succeedContext(context))), +), [layer], options)