From a86cb7e024de92dbbfaf5c8b1797417c71f0e0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 23 Jun 2026 03:57:56 +0200 Subject: [PATCH] Refactor Component --- packages/effect-fc-next/src/Component.new.ts | 385 ------------------- packages/effect-fc-next/src/Component.ts | 28 +- 2 files changed, 19 insertions(+), 394 deletions(-) delete mode 100644 packages/effect-fc-next/src/Component.new.ts diff --git a/packages/effect-fc-next/src/Component.new.ts b/packages/effect-fc-next/src/Component.new.ts deleted file mode 100644 index a8f3d4a..0000000 --- a/packages/effect-fc-next/src/Component.new.ts +++ /dev/null @@ -1,385 +0,0 @@ -/** 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 37b7cb8..aadc0a9 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -83,16 +83,26 @@ const use = Effect.fnUntraced(function*

>>(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( + const componentRef = React.useRef(null) + const previousServicesRef = React.useRef(null) + + const services = Array.from( Context.omit(...self.nonReactiveTags)(contextRef.current).mapUnsafe.values() - )) + ) + + if ( + !componentRef.current || + !previousServicesRef.current || + services.length !== previousServicesRef.current.length || + !Equivalence.Array(Equivalence.strictEqual())(services, previousServicesRef.current) + ) { + previousServicesRef.current = services + const f = self.asFunctionComponent(contextRef) + self.setFunctionComponentName(f) + componentRef.current = self.transformFunctionComponent(f) + } + + return componentRef.current })