From d0bc4e4903a715c3a70675553789cf55cc51fcde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 25 Feb 2026 03:09:51 +0100 Subject: [PATCH 1/2] Refactor Component --- packages/effect-fc/src/Async.ts | 28 ++-- packages/effect-fc/src/Component.ts | 142 ++++++++++-------- packages/effect-fc/src/Memoized.ts | 28 ++-- .../src/routes/dev/async-rendering.tsx | 6 +- packages/example/src/routes/dev/context.tsx | 2 +- packages/example/src/routes/dev/memo.tsx | 4 +- packages/example/src/routes/form.tsx | 4 +- packages/example/src/routes/index.tsx | 2 +- packages/example/src/todo/Todo.tsx | 2 +- packages/example/src/todo/Todos.tsx | 2 +- 10 files changed, 118 insertions(+), 102 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index 3d1f02b..af43edf 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -7,29 +7,27 @@ import * as Component from "./Component.js" export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export type TypeId = typeof TypeId -export interface Async extends Async.Options { +export interface Async extends AsyncOptions { readonly [TypeId]: TypeId } -export namespace Async { - export interface Options { - readonly defaultFallback?: React.ReactNode - } - - export type Props = Omit +export interface AsyncOptions { + readonly defaultFallback?: React.ReactNode } +export type AsyncProps = Omit -const AsyncProto = Object.freeze({ + +export const AsyncPrototype = Object.freeze({ [TypeId]: TypeId, - makeFunctionComponent

( + asFunctionComponent

( this: Component.Component & Async, runtimeRef: React.RefObject>>, ) { const SuspenseInner = (props: { readonly promise: Promise }) => React.use(props.promise) - return ({ fallback, name, ...props }: Async.Props) => { + return ({ fallback, name, ...props }: AsyncProps) => { const promise = Runtime.runPromise(runtimeRef.current)( Effect.andThen( Component.useScope([], this), @@ -54,7 +52,7 @@ export const async = >( ): ( & Omit> & Component.Component< - Component.Component.Props & Async.Props, + Component.Component.Props & AsyncProps, Component.Component.Success, Component.Component.Error, Component.Component.Context @@ -63,22 +61,22 @@ export const async = >( ) => Object.setPrototypeOf( Object.assign(function() {}, self), Object.freeze(Object.setPrototypeOf( - Object.assign({}, AsyncProto), + Object.assign({}, AsyncPrototype), Object.getPrototypeOf(self), )), ) export const withOptions: { & Async>( - options: Partial + options: Partial ): (self: T) => T & Async>( self: T, - options: Partial, + options: Partial, ): T } = Function.dual(2, & Async>( self: T, - options: Partial, + options: Partial, ): T => Object.setPrototypeOf( Object.assign(function() {}, self, options), Object.getPrototypeOf(self), diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 3cd4b2d..8a21b5b 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, type Duration, Effect, Effectable, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, Layer, ManagedRuntime, Option, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect" +import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, Fiber, Function, HashMap, identity, Layer, ManagedRuntime, Option, Pipeable, Predicate, Ref, Runtime, Scope, Tracer, type Utils } from "effect" import * as React from "react" -import { Memoized } from "./index.js" export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") @@ -16,10 +15,7 @@ export type TypeId = typeof TypeId * - a constructor-like object with component metadata and options */ export interface Component

-extends - Effect.Effect<(props: P) => A, never, Exclude>, - Component.Options -{ +extends ComponentPrototype, ComponentOptions { new(_: never): Record readonly [TypeId]: TypeId readonly "~Props": P @@ -28,11 +24,6 @@ extends readonly "~Context": R readonly body: (props: P) => Effect.Effect - - /** @internal */ - makeFunctionComponent( - runtimeRef: React.Ref>> - ): (props: P) => A } export declare namespace Component { @@ -42,56 +33,29 @@ export declare namespace Component { export type Context> = [T] extends [Component] ? R : never export type AsComponent> = Component, Success, Error, Context> - - /** - * Options that can be set on the component - */ - export interface Options { - /** Custom displayName for React DevTools and debugging. */ - readonly displayName?: string - - /** - * Strategy used when executing finalizers on unmount/scope close. - * @default ExecutionStrategy.sequential - */ - readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy - - /** - * Debounce time before executing finalizers after component unmount. - * Helps avoid unnecessary work during fast remount/remount cycles. - * @default "100 millis" - */ - readonly finalizerExecutionDebounce: Duration.DurationInput - } } -const ComponentProto = Object.freeze({ - ...Effectable.CommitPrototype, +export interface ComponentPrototype

+extends Pipeable.Pipeable { + readonly [TypeId]: TypeId + readonly use: Effect.Effect<(props: P) => A, never, Exclude> + + asFunctionComponent( + runtimeRef: React.Ref>> + ): (props: P) => A + + setFunctionComponentName(f: React.FC

): void + transformFunctionComponent(f: React.FC

): React.FC

+} + +export const ComponentPrototype: ComponentPrototype = Object.freeze({ [TypeId]: TypeId, + ...Pipeable.Prototype, - commit: Effect.fnUntraced(function*

( - this: Component - ) { - // biome-ignore lint/style/noNonNullAssertion: React ref initialization - const runtimeRef = React.useRef>>(null!) - runtimeRef.current = yield* Effect.runtime>() + get use() { return use(this) }, - return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction( - (_services: readonly any[]) => Effect.sync(() => { - const f: React.FC

= this.makeFunctionComponent(runtimeRef) - f.displayName = this.displayName ?? "Anonymous" - return Memoized.isMemoized(this) - ? React.memo(f, this.propsAreEqual) - : f - }), - Equivalence.array(Equivalence.strict()), - )))[0](Array.from( - Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() - )) - }), - - makeFunctionComponent

( + asFunctionComponent

( this: Component, runtimeRef: React.RefObject>>, ) { @@ -102,14 +66,62 @@ const ComponentProto = Object.freeze({ ) ) }, + + setFunctionComponentName

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

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

( + self: Component +) { + // biome-ignore lint/style/noNonNullAssertion: React ref initialization + const runtimeRef = React.useRef>>(null!) + runtimeRef.current = yield* Effect.runtime>() + + return yield* React.useState(() => Runtime.runSync(runtimeRef.current)(Effect.cachedFunction( + (_services: readonly any[]) => Effect.sync(() => { + const f: React.FC

= self.asFunctionComponent(runtimeRef) + self.setFunctionComponentName(f) + return self.transformFunctionComponent(f) + }), + Equivalence.array(Equivalence.strict()), + )))[0](Array.from( + Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values() + )) +}) + + +export interface ComponentOptions { + /** Custom displayName for React DevTools and debugging. */ + readonly displayName?: string + + /** + * Strategy used when executing finalizers on unmount/scope close. + * @default ExecutionStrategy.sequential + */ + readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy + + /** + * Debounce time before executing finalizers after component unmount. + * Helps avoid unnecessary work during fast remount/remount cycles. + * @default "100 millis" + */ + readonly finalizerExecutionDebounce: Duration.DurationInput +} + +export const defaultOptions: ComponentOptions = { finalizerExecutionStrategy: ExecutionStrategy.sequential, finalizerExecutionDebounce: "100 millis", } -const nonReactiveTags = [Tracer.ParentSpan] as const + +export const nonReactiveTags = [Tracer.ParentSpan] as const export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId) @@ -365,7 +377,7 @@ export const make: ( Object.assign(function() {}, defaultOptions, { body: Effect.fn(spanNameOrBody as any, ...pipeables), }), - ComponentProto, + ComponentPrototype, ) } else { @@ -375,7 +387,7 @@ export const make: ( body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []), displayName: spanNameOrBody, }), - ComponentProto, + ComponentPrototype, ) } } @@ -401,14 +413,14 @@ export const makeUntraced: ( Object.assign(function() {}, defaultOptions, { body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []), }), - ComponentProto, + ComponentPrototype, ) : (body: any, ...pipeables: any[]) => Object.setPrototypeOf( Object.assign(function() {}, defaultOptions, { body: Effect.fnUntraced(body, ...pipeables as []), displayName: spanNameOrBody, }), - ComponentProto, + ComponentPrototype, ) ) @@ -417,15 +429,15 @@ export const makeUntraced: ( */ export const withOptions: { >( - options: Partial + options: Partial ): (self: T) => T >( self: T, - options: Partial, + options: Partial, ): T } = Function.dual(2, >( self: T, - options: Partial, + options: Partial, ): T => Object.setPrototypeOf( Object.assign(function() {}, self, options), Object.getPrototypeOf(self), @@ -477,7 +489,7 @@ export const withRuntime: { context: React.Context>, ) => function WithRuntime(props: P) { return React.createElement( - Runtime.runSync(React.useContext(context))(self), + Runtime.runSync(React.useContext(context))(self.use), props, ) }) diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index 61cea91..e5d5b24 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -1,24 +1,30 @@ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ import { type Equivalence, Function, Predicate } from "effect" +import * as React from "react" import type * as Component from "./Component.js" export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export type TypeId = typeof TypeId -export interface Memoized

extends Memoized.Options

{ +export interface Memoized

extends MemoizedOptions

{ readonly [TypeId]: TypeId } -export namespace Memoized { - export interface Options

{ - readonly propsAreEqual?: Equivalence.Equivalence

- } +export interface MemoizedOptions

{ + readonly propsEquivalence?: Equivalence.Equivalence

} -const MemoizedProto = Object.freeze({ - [TypeId]: TypeId +export const MemoizedPrototype = Object.freeze({ + [TypeId]: TypeId, + + transformComponent

( + this: Memoized

, + f: React.FC

, + ) { + return React.memo(f, this.propsEquivalence) + }, } as const) @@ -29,22 +35,22 @@ export const memoized = >( ): T & Memoized> => Object.setPrototypeOf( Object.assign(function() {}, self), Object.freeze(Object.setPrototypeOf( - Object.assign({}, MemoizedProto), + Object.assign({}, MemoizedPrototype), Object.getPrototypeOf(self), )), ) export const withOptions: { & Memoized>( - options: Partial>> + options: Partial>> ): (self: T) => T & Memoized>( self: T, - options: Partial>>, + options: Partial>>, ): T } = Function.dual(2, & Memoized>( self: T, - options: Partial>>, + options: Partial>>, ): T => Object.setPrototypeOf( Object.assign(function() {}, self, options), Object.getPrototypeOf(self), diff --git a/packages/example/src/routes/dev/async-rendering.tsx b/packages/example/src/routes/dev/async-rendering.tsx index 5bd2e6a..c057745 100644 --- a/packages/example/src/routes/dev/async-rendering.tsx +++ b/packages/example/src/routes/dev/async-rendering.tsx @@ -9,8 +9,8 @@ import { runtime } from "@/runtime" // Generator version const RouteComponent = Component.makeUntraced(function* AsyncRendering() { - const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent - const AsyncComponentFC = yield* AsyncComponent + const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent.use + const AsyncComponentFC = yield* AsyncComponent.use const [input, setInput] = React.useState("") return ( @@ -51,7 +51,7 @@ const RouteComponent = Component.makeUntraced(function* AsyncRendering() { class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() { - const SubComponentFC = yield* SubComponent + const SubComponentFC = yield* SubComponent.use yield* Effect.sleep("500 millis") // Async operation // Cannot use React hooks after the async operation diff --git a/packages/example/src/routes/dev/context.tsx b/packages/example/src/routes/dev/context.tsx index f512353..b1212c5 100644 --- a/packages/example/src/routes/dev/context.tsx +++ b/packages/example/src/routes/dev/context.tsx @@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() { const ContextView = Component.makeUntraced("ContextView")(function*() { const [serviceValue, setServiceValue] = React.useState("test") const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue]) - const SubComponentFC = yield* Effect.provide(SubComponent, yield* Component.useContext(SubServiceLayer)) + const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContext(SubServiceLayer)) return ( diff --git a/packages/example/src/routes/dev/memo.tsx b/packages/example/src/routes/dev/memo.tsx index 87ece9c..ae672bd 100644 --- a/packages/example/src/routes/dev/memo.tsx +++ b/packages/example/src/routes/dev/memo.tsx @@ -17,8 +17,8 @@ const RouteComponent = Component.makeUntraced("RouteComponent")(function*() { onChange={e => setValue(e.target.value)} /> - {yield* Effect.map(SubComponent, FC => )} - {yield* Effect.map(MemoizedSubComponent, FC => )} + {yield* Effect.map(SubComponent.use, FC => )} + {yield* Effect.map(MemoizedSubComponent.use, FC => )} ) }).pipe( diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index dabbe70..3060dca 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -70,7 +70,7 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi ]) const runPromise = yield* Component.useRunPromise() - const TextFieldFormInputFC = yield* TextFieldFormInput + const TextFieldFormInputFC = yield* TextFieldFormInput.use yield* Component.useOnMount(() => Effect.gen(function*() { yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) @@ -117,7 +117,7 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi const RegisterPage = Component.makeUntraced("RegisterPage")(function*() { const RegisterFormViewFC = yield* Effect.provide( - RegisterFormView, + RegisterFormView.use, yield* Component.useContext(RegisterForm.Default), ) diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index f9146cc..cd9ffa8 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -10,7 +10,7 @@ const TodosStateLive = TodosState.Default("todos") const Index = Component.makeUntraced("Index")(function*() { const TodosFC = yield* Effect.provide( - Todos, + Todos.use, yield* Component.useContext(TodosStateLive), ) diff --git a/packages/example/src/todo/Todo.tsx b/packages/example/src/todo/Todo.tsx index cf4a46f..a26a511 100644 --- a/packages/example/src/todo/Todo.tsx +++ b/packages/example/src/todo/Todo.tsx @@ -83,7 +83,7 @@ export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoPr const runSync = yield* Component.useRunSync() const runPromise = yield* Component.useRunPromise() - const TextFieldFormInputFC = yield* TextFieldFormInput + const TextFieldFormInputFC = yield* TextFieldFormInput.use return ( diff --git a/packages/example/src/todo/Todos.tsx b/packages/example/src/todo/Todos.tsx index ce95844..2718d39 100644 --- a/packages/example/src/todo/Todos.tsx +++ b/packages/example/src/todo/Todos.tsx @@ -14,7 +14,7 @@ export class Todos extends Component.makeUntraced("Todos")(function*() { Effect.addFinalizer(() => Console.log("Todos unmounted")), )) - const TodoFC = yield* Todo + const TodoFC = yield* Todo.use return ( From a73da25b8cbaea3cc3343951b78c72e5b0140253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 25 Feb 2026 03:18:24 +0100 Subject: [PATCH 2/2] Fix --- packages/effect-fc/src/Memoized.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index e5d5b24..33be421 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -19,7 +19,7 @@ export interface MemoizedOptions

{ export const MemoizedPrototype = Object.freeze({ [TypeId]: TypeId, - transformComponent

( + transformFunctionComponent

( this: Memoized

, f: React.FC

, ) {