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 126 additions and 23 deletions
Showing only changes of commit cce1d6e485 - Show all commits
+2 -2
View File
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** 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, 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"
@@ -155,7 +155,7 @@ export interface ComponentOptions {
}
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
nonReactiveTags: [Tracer.ParentSpan, Scheduler.Scheduler, Layer.CurrentMemoMap],
finalizerExecutionStrategy: "sequential",
finalizerExecutionDebounce: "100 millis",
}
+119 -21
View File
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, Layer } from "effect"
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, HashMap, Layer, Ref } from "effect"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "../src/Component.js"
@@ -13,7 +13,7 @@ afterEach(() => {
})
describe("Component", () => {
it("runs useOnMount only once across rerenders", async () => {
it("does not rerun useOnMount across rerenders after Strict Mode initialization", async () => {
const onMount = vi.fn(() => Effect.succeed("mounted"))
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
@@ -32,7 +32,7 @@ describe("Component", () => {
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(1)
expect(onMount).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -40,7 +40,7 @@ describe("Component", () => {
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(1)
expect(onMount).toHaveBeenCalledTimes(2)
view.unmount()
await runtime.runtime.dispose()
@@ -68,7 +68,7 @@ describe("Component", () => {
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -77,7 +77,7 @@ describe("Component", () => {
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -86,7 +86,7 @@ describe("Component", () => {
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(2)
expect(onChange).toHaveBeenCalledTimes(4)
view.unmount()
await runtime.runtime.dispose()
@@ -179,6 +179,8 @@ describe("Component", () => {
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second"))
expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([
"mount:first",
"cleanup:first",
"mount:first",
"cleanup:first",
"mount:second",
@@ -218,9 +220,9 @@ describe("Component", () => {
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
expect(seenCallbacks[0]?.(2)).toBe("a:2")
const initialLength = seenCallbacks.length
const initialCallback = seenCallbacks.at(-1)
expect(initialCallback?.(2)).toBe("a:2")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -229,7 +231,7 @@ describe("Component", () => {
)
await screen.findByText("a:1")
expect(seenCallbacks).toHaveLength(2)
expect(seenCallbacks).toHaveLength(initialLength)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
@@ -238,9 +240,9 @@ describe("Component", () => {
)
await screen.findByText("b:1")
await waitFor(() => expect(seenCallbacks).toHaveLength(3))
expect(seenCallbacks[2]).not.toBe(seenCallbacks[1])
expect(seenCallbacks[2]?.(2)).toBe("b:2")
await waitFor(() => expect(seenCallbacks.length).toBeGreaterThan(initialLength))
expect(seenCallbacks.at(-1)).not.toBe(initialCallback)
expect(seenCallbacks.at(-1)?.(2)).toBe("b:2")
view.unmount()
await runtime.runtime.dispose()
@@ -301,15 +303,18 @@ describe("Component", () => {
const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() {
const service = yield* ValueService
const [count, setCount] = React.useState(0)
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(() => mounts())
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
}))
return <div>{service.value}</div>
return <button onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
}).pipe(
Component.withOptions({ nonReactiveTags: [ValueService] })
Component.withOptions({
nonReactiveTags: [...Component.defaultOptions.nonReactiveTags, ValueService],
})
)
const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) {
@@ -333,22 +338,115 @@ describe("Component", () => {
</runtime.context.Provider>
)
await screen.findByText("first")
expect(mounts).toHaveBeenCalledTimes(1)
await screen.findByText("first:0")
const initialMounts = mounts.mock.calls.length
expect(initialMounts).toBeGreaterThan(0)
expect(unmounts).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole("button", { name: "first:0" }))
await screen.findByText("first:1")
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Parent value="second" />
</runtime.context.Provider>
)
await screen.findByText("second")
expect(mounts).toHaveBeenCalledTimes(1)
await screen.findByText("second:1")
expect(mounts).toHaveBeenCalledTimes(initialMounts)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
it.fails("does not commit effects or retain registered scopes for a discarded Suspense render", async () => {
const setup = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const scopeMap = Context.get(effectRuntime, Component.ScopeMap)
const pending = new Promise<void>(() => {})
const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() {
yield* Component.useReactEffect(() => Effect.sync(setup), [])
React.use(pending)
return <div>committed</div>
}).pipe(
Component.withContext(runtime.context)
)
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<React.Suspense fallback={<div>fallback</div>}>
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
</React.Suspense>
)
})
try {
await screen.findByText("fallback")
expect(setup).not.toHaveBeenCalled()
expect(HashMap.size(Effect.runSync(Ref.get(scopeMap.ref)))).toBe(0)
}
finally {
view.unmount()
await runtime.runtime.dispose()
}
})
it.fails("clears ScopeMap 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)
let resolve!: () => void
const pending = new Promise<void>(complete => {
resolve = complete
})
const Probe = Component.makeUntraced("RetriedSuspenseProbe")(function*() {
React.use(pending)
yield* Component.useReactEffect(() => Effect.gen(function*() {
yield* Effect.sync(() => lifecycle("mount"))
yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle("cleanup")))
}), [])
return <div>committed</div>
}).pipe(
Component.withContext(runtime.context)
)
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<React.Suspense fallback={<div>fallback</div>}>
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
</React.Suspense>
)
})
try {
await screen.findByText("fallback")
await act(async () => {
resolve()
await pending
})
await screen.findByText("committed")
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount"))
view.unmount()
await waitFor(() => expect(HashMap.size(Effect.runSync(Ref.get(scopeMap.ref)))).toBe(0))
expect(lifecycle.mock.calls.filter(([message]) => message === "mount")).toHaveLength(2)
expect(lifecycle.mock.calls.filter(([message]) => message === "cleanup")).toHaveLength(2)
}
finally {
view.unmount()
await runtime.runtime.dispose()
}
})
})
+4
View File
@@ -0,0 +1,4 @@
import { configure } from "@testing-library/react"
configure({ reactStrictMode: true })
+1
View File
@@ -5,5 +5,6 @@ export default defineConfig({
test: {
environment: "jsdom",
include: ["test/**/*.test.ts?(x)"],
setupFiles: ["test/setup.ts"],
},
})