diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index 11e8a02..6023781 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/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 { Cause, Context, type Duration, Effect, Equal, Equivalence, Exit, Fiber, Function, HashMap, identity, Layer, Option, Pipeable, Predicate, Ref, Scheduler, Scope, Tracer } from "effect" +import { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, Scheduler, Scope, Tracer } from "effect" import * as React from "react" import * as ScopeRegistry from "./ScopeRegistry.js" @@ -668,54 +668,26 @@ export const useScope = Effect.fnUntraced(function*( // biome-ignore lint/style/noNonNullAssertion: context initialization const contextRef = React.useRef>(null!) contextRef.current = yield* Effect.context() + const registry = yield* ScopeRegistry.ScopeRegistry as unknown as Effect.Effect - const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe( - Effect.bind("scopeRegistryRef", () => Effect.map( - ScopeRegistry.ScopeRegistry as unknown as Effect.Effect, - scopeRegistry => scopeRegistry.ref, - )), - Effect.let("key", () => Equal.byReference({})), - Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), - Effect.tap(({ scopeRegistryRef, key, scope }) => - Ref.update(scopeRegistryRef, HashMap.set(key, Equal.byReference({ - scope, - closeFiber: Option.none(), - }))) - ), + const [key, scope] = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.gen(function*() { + const key = ScopeRegistry.makeKey() + const entry = yield* registry.register(key, { + finalizerExecutionStrategy: options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy, + finalizerExecutionDebounce: options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce, + }) + + return [key, entry.scope] // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList - )), deps) + })), deps) // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" - React.useEffect(() => Effect.runSyncWith(contextRef.current)( - (ScopeRegistry.ScopeRegistry as unknown as Effect.Effect).pipe( - Effect.map(scopeRegistry => scopeRegistry.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(exit => Exit.match(exit, { - onSuccess: () => Ref.update(ref, HashMap.remove(key)), - onFailure: cause => Cause.hasInterruptsOnly(cause) - ? Effect.void - : Ref.update(ref, HashMap.remove(key)), - })), - Effect.forkDetach, - ), - fiber => Ref.update(ref, HashMap.set(key, Equal.byReference({ - scope, - closeFiber: Option.some(fiber), - }))), - )) - ), - ) - ), [key]) + React.useEffect(() => { + Effect.runSyncWith(contextRef.current)(registry.commit(key)) + return () => { + Effect.runSyncWith(contextRef.current)(registry.release(key)) + } + }, [key]) return scope }) diff --git a/packages/effect-fc-next/src/ReactRuntime.ts b/packages/effect-fc-next/src/ReactRuntime.ts index e9e18aa..52fd82c 100644 --- a/packages/effect-fc-next/src/ReactRuntime.ts +++ b/packages/effect-fc-next/src/ReactRuntime.ts @@ -17,7 +17,7 @@ export interface ReactRuntime { const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const) -export const preludeLayer: Layer.Layer = ScopeRegistry.ScopeRegistry.layer +export const preludeLayer: Layer.Layer = ScopeRegistry.layer export const isReactRuntime = (u: unknown): u is ReactRuntime => Predicate.hasProperty(u, ReactRuntimeTypeId) diff --git a/packages/effect-fc-next/src/ScopeRegistry.ts b/packages/effect-fc-next/src/ScopeRegistry.ts index d664426..7cbb846 100644 --- a/packages/effect-fc-next/src/ScopeRegistry.ts +++ b/packages/effect-fc-next/src/ScopeRegistry.ts @@ -1,4 +1,185 @@ -import { Context, Effect, type Fiber, HashMap, Layer, type Option, Ref, type Scope } from "effect" +import { type Cause, Context, DateTime, type Duration, Effect, Equal, Exit, HashMap, Layer, Option, 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 { + let earliest: DateTime.Utc | undefined + + for (const entry of HashMap.values(entries)) { + if ( + entry.leases === 0 && + entry.expiresAt._tag === "Some" && + (!earliest || DateTime.isLessThan(entry.expiresAt.value, earliest)) + ) + earliest = entry.expiresAt.value + } + + return Option.fromNullishOr(earliest) + } +} + +export const make: Effect.Effect = Effect.gen(function*() { + return new ScopeRegistryServiceImpl( + yield* SubscriptionRef.make(HashMap.empty()), + yield* Semaphore.make(1), + ) +}) /** @@ -7,20 +188,14 @@ import { Context, Effect, type Fiber, HashMap, Layer, type Option, Ref, type Sco * 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-fc/ScopeRegistry/ScopeRegistry" -) { - static readonly layer = Layer.effect(ScopeRegistry, Effect.map( - Ref.make(HashMap.empty()), - ref => ({ ref }), - )) -} +export class ScopeRegistry extends Context.Service()( + "@effect-view/ScopeRegistry/ScopeRegistry" +) {} -export declare namespace ScopeRegistry { - export interface Entry { - readonly scope: Scope.Closeable - readonly closeFiber: Option.Option> - } -} +export const layer = Layer.effect(ScopeRegistry, Effect.tap( + make, + registry => Effect.andThen( + Effect.addFinalizer(() => registry.dispose), + Effect.forkScoped(registry.run), + ), +)) diff --git a/packages/effect-fc-next/test/Component.test.tsx b/packages/effect-fc-next/test/Component.test.tsx index 7e777f2..e015212 100644 --- a/packages/effect-fc-next/test/Component.test.tsx +++ b/packages/effect-fc-next/test/Component.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" -import { Context, Effect, HashMap, Layer, Ref } from "effect" +import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect" import * as React from "react" import { afterEach, describe, expect, it, vi } from "vitest" import * as Component from "../src/Component.js" @@ -362,7 +362,7 @@ describe("Component", () => { await runtime.runtime.dispose() }) - it.fails("does not commit effects or retain registered scopes for a discarded Suspense render", async () => { + it("does not commit effects or retain registered scopes for a discarded Suspense render", async () => { const setup = vi.fn() const runtime = ReactRuntime.make(Layer.empty) const effectRuntime = await runtime.runtime.context() @@ -391,7 +391,7 @@ describe("Component", () => { try { await screen.findByText("fallback") expect(setup).not.toHaveBeenCalled() - expect(HashMap.size(Effect.runSync(Ref.get(scopeRegistry.ref)))).toBe(0) + await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0)) } finally { view.unmount() @@ -399,7 +399,45 @@ describe("Component", () => { } }) - it.fails("clears ScopeRegistry after a suspended render retries, commits, and unmounts", async () => { + it("keeps committed scopes alive without heartbeats", async () => { + const acquisitions = vi.fn() + const releases = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await runtime.runtime.context() + const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry) + + const Probe = Component.makeUntraced("CommittedScopeProbe")(function*() { + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.sync(acquisitions) + yield* Effect.addFinalizer(() => Effect.sync(releases)) + })) + + return
committed
+ }).pipe( + Component.withOptions({ finalizerExecutionDebounce: "10 millis" }), + Component.withContext(runtime.context), + ) + + const view = render( + + + + ) + + await screen.findByText("committed") + await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1)) + await new Promise(resolve => setTimeout(resolve, 30)) + + expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(1) + expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1) + + view.unmount() + await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length)) + await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0)) + await runtime.runtime.dispose() + }) + + it("clears ScopeRegistry after a suspended render retries, commits, and unmounts", async () => { const lifecycle = vi.fn<(message: string) => void>() const runtime = ReactRuntime.make(Layer.empty) const effectRuntime = await runtime.runtime.context() @@ -441,7 +479,7 @@ describe("Component", () => { await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount")) view.unmount() - await waitFor(() => expect(HashMap.size(Effect.runSync(Ref.get(scopeRegistry.ref)))).toBe(0)) + await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0)) expect(lifecycle.mock.calls.filter(([message]) => message === "mount")).toHaveLength(2) expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2) }