Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
4 changed files with 253 additions and 68 deletions
Showing only changes of commit cb62639829 - Show all commits
+17 -45
View File
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ /** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** 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 { Context, type Duration, Effect, Equivalence, Exit, Function, identity, Layer, Pipeable, Predicate, Scheduler, Scope, Tracer } from "effect"
import * as React from "react" import * as React from "react"
import * as ScopeRegistry from "./ScopeRegistry.js" import * as ScopeRegistry from "./ScopeRegistry.js"
@@ -668,54 +668,26 @@ export const useScope = Effect.fnUntraced(function*(
// biome-ignore lint/style/noNonNullAssertion: context initialization // biome-ignore lint/style/noNonNullAssertion: context initialization
const contextRef = React.useRef<Context.Context<never>>(null!) const contextRef = React.useRef<Context.Context<never>>(null!)
contextRef.current = yield* Effect.context() contextRef.current = yield* Effect.context()
const registry = yield* ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistryService>
const { key, scope } = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.Do.pipe( const [key, scope] = React.useMemo(() => Effect.runSyncWith(contextRef.current)(Effect.gen(function*() {
Effect.bind("scopeRegistryRef", () => Effect.map( const key = ScopeRegistry.makeKey()
ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>, const entry = yield* registry.register(key, {
scopeRegistry => scopeRegistry.ref, finalizerExecutionStrategy: options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy,
)), finalizerExecutionDebounce: options?.finalizerExecutionDebounce ?? defaultOptions.finalizerExecutionDebounce,
Effect.let("key", () => Equal.byReference({})), })
Effect.bind("scope", () => Scope.make(options?.finalizerExecutionStrategy ?? defaultOptions.finalizerExecutionStrategy)),
Effect.tap(({ scopeRegistryRef, key, scope }) => return [key, entry.scope]
Ref.update(scopeRegistryRef, HashMap.set(key, Equal.byReference({
scope,
closeFiber: Option.none(),
})))
),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList // biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
)), deps) })), deps)
// 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(() => {
(ScopeRegistry.ScopeRegistry as unknown as Effect.Effect<ScopeRegistry.ScopeRegistry["Service"]>).pipe( Effect.runSyncWith(contextRef.current)(registry.commit(key))
Effect.map(scopeRegistry => scopeRegistry.ref), return () => {
Effect.tap(ref => Ref.get(ref).pipe( Effect.runSyncWith(contextRef.current)(registry.release(key))
Effect.flatMap(map => Effect.fromOption(HashMap.get(map, key))), }
Effect.flatMap(entry => Option.match(entry.closeFiber, { }, [key])
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])
return scope return scope
}) })
+1 -1
View File
@@ -17,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<ScopeRegistry.ScopeRegistry> = ScopeRegistry.ScopeRegistry.layer export const preludeLayer: Layer.Layer<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)
+192 -17
View File
@@ -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<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>
register(
key: ScopeRegistryService.Key,
options: ScopeRegistryService.RegisterOptions,
): Effect.Effect<ScopeRegistryService.Entry>
commit(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError>
readonly run: Effect.Effect<void>
readonly dispose: Effect.Effect<void>
}
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<DateTime.Utc>
readonly finalizerExecutionDebounce: Duration.Input
}
}
export const makeKey = (): ScopeRegistryService.Key => Equal.byReference({})
export class ScopeRegistryServiceImpl implements ScopeRegistryService {
constructor(
readonly ref: SubscriptionRef.SubscriptionRef<HashMap.HashMap<ScopeRegistryService.Key, ScopeRegistryService.Entry>>,
readonly runSemaphore: Semaphore.Semaphore,
) {}
register(
key: ScopeRegistryService.Key,
options: ScopeRegistryService.RegisterOptions,
): Effect.Effect<ScopeRegistryService.Entry> {
return Effect.gen({ self: this }, function*() {
const now = yield* DateTime.now
const entry = Equal.byReference<ScopeRegistryService.Entry>({
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<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
return this.updateEntry(key, entry => Equal.byReference({
...entry,
leases: entry.leases + 1,
expiresAt: Option.none(),
}))
}
release(key: ScopeRegistryService.Key): Effect.Effect<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
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<void> {
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<void> {
return SubscriptionRef.getAndSet(
this.ref,
HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>(),
).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<ScopeRegistryService.Entry, Cause.NoSuchElementError> {
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<void> {
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<ScopeRegistryService.Key, ScopeRegistryService.Entry>,
): Option.Option<DateTime.Utc> {
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<ScopeRegistryService> = Effect.gen(function*() {
return new ScopeRegistryServiceImpl(
yield* SubscriptionRef.make(HashMap.empty<ScopeRegistryService.Key, ScopeRegistryService.Entry>()),
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, * 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. * including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
*/ */
export class ScopeRegistry extends Context.Service<ScopeRegistry, { export class ScopeRegistry extends Context.Service<ScopeRegistry, ScopeRegistryService>()(
readonly ref: Ref.Ref<HashMap.HashMap<object, ScopeRegistry.Entry>> "@effect-view/ScopeRegistry/ScopeRegistry"
}>()( ) {}
"@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 const layer = Layer.effect(ScopeRegistry, Effect.tap(
export interface Entry { make,
readonly scope: Scope.Closeable registry => Effect.andThen(
readonly closeFiber: Option.Option<Fiber.Fiber<void>> Effect.addFinalizer(() => registry.dispose),
} Effect.forkScoped(registry.run),
} ),
))
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" 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 * 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"
@@ -362,7 +362,7 @@ describe("Component", () => {
await runtime.runtime.dispose() 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 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()
@@ -391,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(scopeRegistry.ref)))).toBe(0) await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
} }
finally { finally {
view.unmount() 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 <div>committed</div>
}).pipe(
Component.withOptions({ finalizerExecutionDebounce: "10 millis" }),
Component.withContext(runtime.context),
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
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 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()
@@ -441,7 +479,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(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 === "mount")).toHaveLength(2)
expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2) expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2)
} }