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 */ /** 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 { 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 React from "react"
import * as ScopeRegistry from "./ScopeRegistry.js"
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component") 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 declare namespace useScope {
export interface Options { export interface Options {
readonly finalizerExecutionStrategy?: "sequential" | "parallel" readonly finalizerExecutionStrategy?: "sequential" | "parallel"
@@ -694,14 +670,14 @@ export const useScope = Effect.fnUntraced(function*(
contextRef.current = yield* Effect.context() contextRef.current = yield* Effect.context()
const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe( const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe(
Effect.bind("scopeMapRef", () => Effect.map( Effect.bind("scopeRegistryRef", () => Effect.map(
ScopeMap as unknown as Effect.Effect<ScopeMap["Service"]>, ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>,
scopeMap => scopeMap.ref, scopeRegistry => scopeRegistry.ref,
)), )),
Effect.let("key", () => Equal.byReference({})), Effect.let("key", () => Equal.byReference({})),
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)), Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)),
Effect.tap(({ scopeMapRef, key, scope }) => Effect.tap(({ scopeRegistryRef, key, scope }) =>
Ref.update(scopeMapRef, HashMap.set(key, Equal.byReference({ Ref.update(scopeRegistryRef, HashMap.set(key, Equal.byReference({
scope, scope,
closeFiber: Option.none(), closeFiber: Option.none(),
}))) })))
@@ -711,8 +687,8 @@ export const useScope = Effect.fnUntraced(function*(
// biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key" // biome-ignore lint/correctness/useExhaustiveDependencies: only reactive on "key"
React.useEffect(() => Effect.runSyncWith(contextRef.current)( React.useEffect(() => Effect.runSyncWith(contextRef.current)(
(ScopeMap as unknown as Effect.Effect<ScopeMap["Service"]>).pipe( (ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>).pipe(
Effect.map(scopeMap => scopeMap.ref), Effect.map(scopeRegistry => scopeRegistry.ref),
Effect.tap(ref => Ref.get(ref).pipe( Effect.tap(ref => Ref.get(ref).pipe(
Effect.flatMap(map => Effect.fromOption(HashMap.get(map, key))), Effect.flatMap(map => Effect.fromOption(HashMap.get(map, key))),
Effect.flatMap(entry => Option.match(entry.closeFiber, { 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 { type Context, Effect, Layer, ManagedRuntime, Predicate } from "effect"
import * as React from "react" import * as React from "react"
import * as Component from "./Component.js" import * as Component from "./Component.js"
import * as ScopeRegistry from "./ScopeRegistry.js"
export const ReactRuntimeTypeId: unique symbol = Symbol.for("@effect-fc/ReactRuntime/ReactRuntime") 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) 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) 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 Query from "./Query.js"
export * as QueryClient from "./QueryClient.js" export * as QueryClient from "./QueryClient.js"
export * as ReactRuntime from "./ReactRuntime.js" export * as ReactRuntime from "./ReactRuntime.js"
export * as ScopeRegistry from "./ScopeRegistry.js"
export * as SetStateAction from "./SetStateAction.js" export * as SetStateAction from "./SetStateAction.js"
export * as Stream from "./Stream.js" export * as Stream from "./Stream.js"
export * as View from "./View.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 { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "../src/Component.js" import * as Component from "../src/Component.js"
import * as ReactRuntime from "../src/ReactRuntime.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") {} class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
@@ -365,7 +366,7 @@ describe("Component", () => {
const setup = vi.fn() const setup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty) const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context() 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 pending = new Promise<void>(() => {})
const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() { const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() {
@@ -390,7 +391,7 @@ describe("Component", () => {
try { try {
await screen.findByText("fallback") await screen.findByText("fallback")
expect(setup).not.toHaveBeenCalled() 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 { finally {
view.unmount() 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 lifecycle = vi.fn<(message: string) => void>()
const runtime = ReactRuntime.make(Layer.empty) const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context() const effectRuntime = await runtime.runtime.context()
const scopeMap = Context.get(effectRuntime, Component.ScopeMap) const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
let resolve!: () => void let resolve!: () => void
const pending = new Promise<void>(complete => { const pending = new Promise<void>(complete => {
resolve = complete resolve = complete
@@ -440,7 +441,7 @@ describe("Component", () => {
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount")) await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount"))
view.unmount() 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 === "mount")).toHaveLength(2)
expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2) expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2)
} }