diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 11df434..a03cf5e 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -1,6 +1,6 @@ /** 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, Exit, Function, Layer, ManagedRuntime, Predicate, Ref, Runtime, Scope, Tracer, type Types, type Utils } from "effect" +import { Context, Effect, Effectable, ExecutionStrategy, Exit, Fiber, Function, HashMap, Layer, ManagedRuntime, Option, Predicate, Ref, Runtime, Scope, Tracer, type Types, type Utils } from "effect" import * as React from "react" import { Memoized } from "./index.js" @@ -60,8 +60,8 @@ const ComponentProto = Object.freeze({ ) { const self = this // biome-ignore lint/style/noNonNullAssertion: React ref initialization - const runtimeRef = React.useRef>>(null!) - runtimeRef.current = yield* Effect.runtime>() + const runtimeRef = React.useRef>>(null!) + runtimeRef.current = yield* Effect.runtime>() return React.useRef(function ScopeProvider(props: P) { const scope = Runtime.runSync(runtimeRef.current)(useScope( @@ -413,35 +413,73 @@ export const withRuntime: { }) +export class ComponentScopeMap extends Effect.Service()("effect-fc/Component/ComponentScopeMap", { + effect: Effect.bind( + Effect.Do, + "ref", + () => Ref.make(HashMap.empty()), + ), +}) {} + +export namespace ComponentScopeMap { + export interface Entry { + readonly scope: Scope.CloseableScope + readonly closeFiber: Option.Option> + } +} + + export const useScope: { ( deps: React.DependencyList, options?: ScopeOptions, - ): Effect.Effect + ): Effect.Effect } = Effect.fnUntraced(function*(deps, options) { - const runtime = yield* Effect.runtime() + // biome-ignore lint/style/noNonNullAssertion: context initialization + const runtimeRef = React.useRef>(null!) + runtimeRef.current = 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) + const key = React.useId() + const scopeMap = yield* ComponentScopeMap - React.useEffect(() => Runtime.runSync(runtime)( - Effect.if(isInitialRun, { - onTrue: () => Effect.as( - Ref.set(isInitialRun, false), - () => closeScope(scope, runtime, options), + const scope = React.useMemo(() => Runtime.runSync(runtimeRef.current)(Effect.andThen( + scopeMap.ref, + map => Option.match(HashMap.get(map, key), { + onSome: entry => Effect.succeed(entry.scope), + onNone: () => Effect.tap( + Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential), + scope => Ref.update(scopeMap.ref, HashMap.set(key, { + scope, + closeFiber: Option.none(), + })) ), - - 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) + )), deps) + + // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "scope" + React.useEffect(() => Runtime.runSync(runtimeRef.current)(scopeMap.ref.pipe( + Effect.andThen(HashMap.get(key)), + Effect.tap(entry => Option.match(entry.closeFiber, { + onSome: fiber => Effect.andThen( + Ref.update(scopeMap.ref, HashMap.set(key, { ...entry, closeFiber: Option.none() })), + Fiber.interruptFork(fiber), + ), + onNone: () => Effect.void, + })), + Effect.map(({ scope }) => + () => Runtime.runSync(runtimeRef.current)(Effect.andThen( + Effect.forkDaemon(Effect.andThen( + Effect.sleep("100 millis"), + Scope.close(scope, Exit.void), + )), + fiber => Ref.update(scopeMap.ref, HashMap.set(key, { + scope, + closeFiber: Option.some(fiber), + })), + )) + ), + )), [scope]) return scope }) @@ -572,7 +610,7 @@ export const useContext: { ( layer: Layer.Layer, options?: ScopeOptions, - ): Effect.Effect, E, RIn> + ): Effect.Effect, E, RIn | ComponentScopeMap> } = Effect.fnUntraced(function* ( layer: Layer.Layer, options?: ScopeOptions, diff --git a/packages/effect-fc/src/ReactRuntime.ts b/packages/effect-fc/src/ReactRuntime.ts index 64f1af9..17d86c7 100644 --- a/packages/effect-fc/src/ReactRuntime.ts +++ b/packages/effect-fc/src/ReactRuntime.ts @@ -1,6 +1,7 @@ /** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ -import { Effect, type Layer, ManagedRuntime, Predicate, type Runtime } from "effect" +import { Effect, Layer, ManagedRuntime, Predicate, type Runtime } from "effect" import * as React from "react" +import * as Component from "./Component.js" export const TypeId: unique symbol = Symbol.for("effect-fc/ReactRuntime") @@ -21,9 +22,12 @@ export const isReactRuntime = (u: unknown): u is ReactRuntime export const make = ( layer: Layer.Layer, memoMap?: Layer.MemoMap, -): ReactRuntime => Object.setPrototypeOf( +): ReactRuntime => Object.setPrototypeOf( Object.assign(function() {}, { - runtime: ManagedRuntime.make(layer, memoMap), + runtime: ManagedRuntime.make( + Layer.merge(layer, Component.ComponentScopeMap.Default), + memoMap, + ), // biome-ignore lint/style/noNonNullAssertion: context initialization context: React.createContext>(null!), }), diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index e45047a..ea5d233 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -54,6 +54,11 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi const TextFieldFormInputFC = yield* TextFieldFormInput + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted")) + yield* Console.log("RegisterFormView mounted") + })) + return (