ScopeMap -> ScopeRegistry
Lint / lint (push) Failing after 43s

This commit is contained in:
Julien Valverdé
2026-07-22 22:51:38 +02:00
parent c151b4608f
commit 8fed17789a
5 changed files with 43 additions and 38 deletions
+8 -32
View File
@@ -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<ScopeMap, {
readonly ref: Ref.Ref<HashMap.HashMap<object, ScopeMap.Entry>>
}>()(
"@effect-fc/Component/ScopeMap"
) {
static readonly layer = Layer.effect(ScopeMap, Effect.map(
Ref.make(HashMap.empty<object, ScopeMap.Entry>()),
ref => ({ ref }),
))
}
export declare namespace ScopeMap {
export interface Entry {
readonly scope: Scope.Closeable
readonly closeFiber: Option.Option<Fiber.Fiber<void>>
}
}
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["Service"]>,
scopeMap => scopeMap.ref,
Effect.bind("scopeRegistryRef", () => Effect.map(
ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>,
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<ScopeMap["Service"]>).pipe(
Effect.map(scopeMap => scopeMap.ref),
(ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>).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, {
+2 -1
View File
@@ -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<R, ER> {
const ReactRuntimePrototype = Object.freeze({ [ReactRuntimeTypeId]: ReactRuntimeTypeId } as const)
export const preludeLayer: Layer.Layer<Component.ScopeMap> = Component.ScopeMap.layer
export const preludeLayer: Layer.Layer<ScopeRegistry.ScopeRegistry> = ScopeRegistry.ScopeRegistry.layer
export const isReactRuntime = (u: unknown): u is ReactRuntime<unknown, unknown> => Predicate.hasProperty(u, ReactRuntimeTypeId)
@@ -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<ScopeRegistry, {
readonly ref: Ref.Ref<HashMap.HashMap<object, ScopeRegistry.Entry>>
}>()(
"@effect-fc/ScopeRegistry/ScopeRegistry"
) {
static readonly layer = Layer.effect(ScopeRegistry, Effect.map(
Ref.make(HashMap.empty<object, ScopeRegistry.Entry>()),
ref => ({ ref }),
))
}
export declare namespace ScopeRegistry {
export interface Entry {
readonly scope: Scope.Closeable
readonly closeFiber: Option.Option<Fiber.Fiber<void>>
}
}
+1
View File
@@ -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"
@@ -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, { readonly value: string }>()("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<void>(() => {})
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<void>(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)
}