From 8fed17789a27d92b48e9b5e4d788eb50f891f512 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 22 Jul 2026 22:51:38 +0200 Subject: [PATCH] ScopeMap -> ScopeRegistry --- packages/effect-fc-next/src/Component.ts | 40 ++++--------------- packages/effect-fc-next/src/ReactRuntime.ts | 3 +- packages/effect-fc-next/src/ScopeRegistry.ts | 26 ++++++++++++ packages/effect-fc-next/src/index.ts | 1 + .../effect-fc-next/test/Component.test.tsx | 11 ++--- 5 files changed, 43 insertions(+), 38 deletions(-) create mode 100644 packages/effect-fc-next/src/ScopeRegistry.ts diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index 0147258..11e8a02 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -2,6 +2,7 @@ /** 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 * as React from "react" +import * as ScopeRegistry from "./ScopeRegistry.js" export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") @@ -636,31 +637,6 @@ export const withContext: { }) -/** - * Internal Effect service that maintains a registry of scopes associated with React component instances. - * - * This service is used internally by the `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 ScopeMap extends Context.Service> -}>()( - "@effect-fc/Component/ScopeMap" -) { - static readonly layer = Layer.effect(ScopeMap, Effect.map( - Ref.make(HashMap.empty()), - ref => ({ ref }), - )) -} - -export declare namespace ScopeMap { - export interface Entry { - readonly scope: Scope.Closeable - readonly closeFiber: Option.Option> - } -} - - export declare namespace useScope { export interface Options { readonly finalizerExecutionStrategy?: "sequential" | "parallel" @@ -694,14 +670,14 @@ export const useScope = Effect.fnUntraced(function*( contextRef.current = yield* Effect.context() const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe( - Effect.bind("scopeMapRef", () => Effect.map( - ScopeMap as unknown as Effect.Effect, - scopeMap => scopeMap.ref, + 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(({ scopeMapRef, key, scope }) => - Ref.update(scopeMapRef, HashMap.set(key, Equal.byReference({ + Effect.tap(({ scopeRegistryRef, key, scope }) => + Ref.update(scopeRegistryRef, HashMap.set(key, Equal.byReference({ scope, closeFiber: Option.none(), }))) @@ -711,8 +687,8 @@ export const useScope = Effect.fnUntraced(function*( // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" React.useEffect(() => Effect.runSyncWith(contextRef.current)( - (ScopeMap as unknown as Effect.Effect).pipe( - Effect.map(scopeMap => scopeMap.ref), + (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, { diff --git a/packages/effect-fc-next/src/ReactRuntime.ts b/packages/effect-fc-next/src/ReactRuntime.ts index 0cee1e0..e9e18aa 100644 --- a/packages/effect-fc-next/src/ReactRuntime.ts +++ b/packages/effect-fc-next/src/ReactRuntime.ts @@ -2,6 +2,7 @@ import { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect" import * as React from "react" import * as Component from "./Component.js" +import * as ScopeRegistry from "./ScopeRegistry.js" export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") @@ -16,7 +17,7 @@ export interface ReactRuntime { const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const) -export const preludeLayer: Layer.Layer = Component.ScopeMap.layer +export const preludeLayer: Layer.Layer = ScopeRegistry.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 new file mode 100644 index 0000000..d664426 --- /dev/null +++ b/packages/effect-fc-next/src/ScopeRegistry.ts @@ -0,0 +1,26 @@ +import { Context, Effect, type Fiber, HashMap, Layer, type Option, Ref, type Scope } from "effect" + + +/** + * 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-fc/ScopeRegistry/ScopeRegistry" +) { + static readonly layer = Layer.effect(ScopeRegistry, Effect.map( + Ref.make(HashMap.empty()), + ref => ({ ref }), + )) +} + +export declare namespace ScopeRegistry { + export interface Entry { + readonly scope: Scope.Closeable + readonly closeFiber: Option.Option> + } +} diff --git a/packages/effect-fc-next/src/index.ts b/packages/effect-fc-next/src/index.ts index 33039ed..014737b 100644 --- a/packages/effect-fc-next/src/index.ts +++ b/packages/effect-fc-next/src/index.ts @@ -11,6 +11,7 @@ export * as PubSub from "./PubSub.js" export * as Query from "./Query.js" export * as QueryClient from "./QueryClient.js" export * as ReactRuntime from "./ReactRuntime.js" +export * as ScopeRegistry from "./ScopeRegistry.js" export * as SetStateAction from "./SetStateAction.js" export * as Stream from "./Stream.js" export * as View from "./View.js" diff --git a/packages/effect-fc-next/test/Component.test.tsx b/packages/effect-fc-next/test/Component.test.tsx index 615953e..7e777f2 100644 --- a/packages/effect-fc-next/test/Component.test.tsx +++ b/packages/effect-fc-next/test/Component.test.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { afterEach, describe, expect, it, vi } from "vitest" import * as Component from "../src/Component.js" import * as ReactRuntime from "../src/ReactRuntime.js" +import * as ScopeRegistry from "../src/ScopeRegistry.js" class ValueService extends Context.Service()("ValueService") {} @@ -365,7 +366,7 @@ describe("Component", () => { const setup = vi.fn() const runtime = ReactRuntime.make(Layer.empty) const effectRuntime = await runtime.runtime.context() - const scopeMap = Context.get(effectRuntime, Component.ScopeMap) + const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry) const pending = new Promise(() => {}) const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() { @@ -390,7 +391,7 @@ describe("Component", () => { try { await screen.findByText("fallback") expect(setup).not.toHaveBeenCalled() - expect(HashMap.size(Effect.runSync(Ref.get(scopeMap.ref)))).toBe(0) + expect(HashMap.size(Effect.runSync(Ref.get(scopeRegistry.ref)))).toBe(0) } finally { view.unmount() @@ -398,11 +399,11 @@ describe("Component", () => { } }) - it.fails("clears ScopeMap after a suspended render retries, commits, and unmounts", async () => { + it.fails("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() - const scopeMap = Context.get(effectRuntime, Component.ScopeMap) + const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry) let resolve!: () => void const pending = new Promise(complete => { resolve = complete @@ -440,7 +441,7 @@ describe("Component", () => { await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount")) view.unmount() - await waitFor(() => expect(HashMap.size(Effect.runSync(Ref.get(scopeMap.ref)))).toBe(0)) + await waitFor(() => expect(HashMap.size(Effect.runSync(Ref.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) }