@@ -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,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>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user