import { type Cause, Context, DateTime, type Duration, Effect, Equal, Exit, HashMap, Layer, Option, Order, Scope, Semaphore, Stream, SubscriptionRef } from "effect" export interface ScopeRegistryService { readonly ref: SubscriptionRef.SubscriptionRef> register( key: ScopeRegistryService.Key, options: ScopeRegistryService.RegisterOptions, ): Effect.Effect commit(key: ScopeRegistryService.Key): Effect.Effect release(key: ScopeRegistryService.Key): Effect.Effect readonly run: Effect.Effect readonly dispose: Effect.Effect } export declare namespace ScopeRegistryService { export type Key = object export interface RegisterOptions { readonly finalizerExecutionStrategy: "sequential" | "parallel" readonly finalizerExecutionDebounce: Duration.Input } export interface Entry { readonly scope: Scope.Closeable readonly leases: number readonly expiresAt: Option.Option readonly finalizerExecutionDebounce: Duration.Input } } export const makeKey = (): ScopeRegistryService.Key => Equal.byReference({}) export class ScopeRegistryServiceImpl implements ScopeRegistryService { constructor( readonly ref: SubscriptionRef.SubscriptionRef>, readonly runSemaphore: Semaphore.Semaphore, ) {} register( key: ScopeRegistryService.Key, options: ScopeRegistryService.RegisterOptions, ): Effect.Effect { return Effect.gen({ self: this }, function*() { const now = yield* DateTime.now const entry = Equal.byReference({ scope: yield* Scope.make(options.finalizerExecutionStrategy), leases: 0, expiresAt: Option.some(DateTime.addDuration(now, options.finalizerExecutionDebounce)), finalizerExecutionDebounce: options.finalizerExecutionDebounce, }) yield* SubscriptionRef.update(this.ref, HashMap.set(key, entry)) return entry }) } commit(key: ScopeRegistryService.Key): Effect.Effect { return this.updateEntry(key, entry => Equal.byReference({ ...entry, leases: entry.leases + 1, expiresAt: Option.none(), })) } release(key: ScopeRegistryService.Key): Effect.Effect { return Effect.flatMap(DateTime.now, now => this.updateEntry(key, entry => { if (entry.leases === 0) return entry const leases = entry.leases - 1 return Equal.byReference({ ...entry, leases, expiresAt: leases === 0 ? Option.some(DateTime.addDuration(now, entry.finalizerExecutionDebounce)) : Option.none(), }) })) } get run(): Effect.Effect { return this.runSemaphore.withPermits(1)(SubscriptionRef.changes(this.ref).pipe( Stream.switchMap(entries => Option.match(this.getNextExpiration(entries), { onNone: () => Stream.never, onSome: expiresAt => Stream.fromEffect(DateTime.now.pipe( Effect.flatMap(now => DateTime.isLessThan(now, expiresAt) ? Effect.sleep(DateTime.distance(now, expiresAt)) : Effect.void), Effect.andThen(Effect.uninterruptible(this.closeExpired)), )), })), Stream.runDrain, )) } get dispose(): Effect.Effect { return SubscriptionRef.getAndSet( this.ref, HashMap.empty(), ).pipe( Effect.flatMap(entries => Effect.forEach( HashMap.values(entries), entry => Scope.close(entry.scope, Exit.void), )), Effect.asVoid, ) } updateEntry( key: ScopeRegistryService.Key, f: (entry: ScopeRegistryService.Entry) => ScopeRegistryService.Entry, ): Effect.Effect { return SubscriptionRef.modify(this.ref, entries => { const current = HashMap.get(entries, key) if (current._tag === "None") return [Option.none(), entries] const entry = f(current.value) return [Option.some(entry), HashMap.set(entries, key, entry)] }).pipe(Effect.flatMap(Effect.fromOption)) } get closeExpired(): Effect.Effect { return Effect.flatMap(DateTime.now, now => SubscriptionRef.modify( this.ref, entries => { const expired: ScopeRegistryService.Entry[] = [] const remaining = HashMap.filter(entries, entry => { const shouldClose = entry.leases === 0 && Option.exists( entry.expiresAt, expiresAt => DateTime.isLessThanOrEqualTo(expiresAt, now), ) if (shouldClose) expired.push(entry) return !shouldClose }) return [expired, remaining] }, )).pipe( Effect.flatMap(entries => Effect.forEach( entries, entry => Scope.close(entry.scope, Exit.void), )), Effect.asVoid, ) } getNextExpiration( entries: HashMap.HashMap, ): Option.Option { return HashMap.reduce( entries, Option.none(), (earliest, entry) => Option.match( Option.filter(entry.expiresAt, () => entry.leases === 0), { onNone: () => earliest, onSome: expiresAt => Option.some(Option.match(earliest, { onNone: () => expiresAt, onSome: Order.min(DateTime.Order)(expiresAt), })), }, ), ) } } export const make: Effect.Effect = Effect.gen(function*() { return new ScopeRegistryServiceImpl( yield* SubscriptionRef.make(HashMap.empty()), yield* Semaphore.make(1), ) }) /** * Internal Effect service that maintains a registry of scopes associated with React component instances. * * This service is used internally by the `Component.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 ScopeRegistry extends Context.Service()( "@effect-view/ScopeRegistry/ScopeRegistry" ) {} export const layer = Layer.effect(ScopeRegistry, Effect.tap( make, registry => Effect.andThen( Effect.addFinalizer(() => registry.dispose), Effect.forkScoped(registry.run), ), ))