From fcb29c0d76508b89bda60f04a72592e102e77df0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 7 Jun 2026 15:01:07 +0200 Subject: [PATCH] Add tests --- packages/effect-fc/test/Component.test.tsx | 225 ++++++++++++++++++++- 1 file changed, 223 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/test/Component.test.tsx b/packages/effect-fc/test/Component.test.tsx index 7a7658b..eb846d9 100644 --- a/packages/effect-fc/test/Component.test.tsx +++ b/packages/effect-fc/test/Component.test.tsx @@ -1,10 +1,17 @@ import { render, screen, waitFor } from "@testing-library/react" -import { Effect, Layer } from "effect" -import { describe, expect, it, vi } from "vitest" +import { Context, Effect, Layer } from "effect" +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" +class ValueService extends Context.Tag("ValueService")() {} + +afterEach(() => { + vi.useRealTimers() +}) + describe("Component", () => { it("runs useOnMount only once across rerenders", async () => { const onMount = vi.fn(() => Effect.succeed("mounted")) @@ -130,4 +137,218 @@ describe("Component", () => { expect(cleanup).toHaveBeenCalledTimes(2) await Effect.runPromise(runtime.runtime.disposeEffect) }) + + it("runs useReactEffect setup and cleanup when dependencies change", async () => { + const lifecycle = vi.fn<(message: string) => void>() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect) + + const Probe = Component.makeUntraced("UseReactEffectProbe")(function*(props: { readonly value: string }) { + yield* Component.useReactEffect(() => + Effect.gen(function*() { + yield* Effect.sync(() => lifecycle(`mount:${props.value}`)) + yield* Effect.addFinalizer(() => Effect.sync(() => lifecycle(`cleanup:${props.value}`))) + }), + [props.value]) + + return
{props.value}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first")) + + view.rerender( + + + + ) + + await screen.findByText("second") + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:first")) + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:second")) + + view.unmount() + + await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("cleanup:second")) + expect(lifecycle.mock.calls.map(([message]) => message)).toEqual([ + "mount:first", + "cleanup:first", + "mount:second", + "cleanup:second", + ]) + await Effect.runPromise(runtime.runtime.disposeEffect) + }) + + it("keeps useCallbackSync stable until dependencies change", async () => { + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect) + const seenCallbacks: Array<(value: number) => string> = [] + + const Probe = Component.makeUntraced("UseCallbackSyncProbe")(function*(props: { readonly prefix: string }) { + const callback = yield* Component.useCallbackSync( + (value: number) => Effect.succeed(`${props.prefix}:${value}`), + [props.prefix], + ) + + yield* Component.useOnMount(() => Effect.sync(() => { + seenCallbacks.push(callback) + })) + + React.useEffect(() => { + seenCallbacks.push(callback) + }, [callback]) + + return
{callback(1)}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("a:1") + expect(seenCallbacks).toHaveLength(2) + expect(seenCallbacks[0]).toBe(seenCallbacks[1]) + expect(seenCallbacks[0]?.(2)).toBe("a:2") + + view.rerender( + + + + ) + + await screen.findByText("a:1") + expect(seenCallbacks).toHaveLength(2) + + view.rerender( + + + + ) + + 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") + + view.unmount() + await Effect.runPromise(runtime.runtime.disposeEffect) + }) + + it("delays cleanup according to finalizerExecutionDebounce", async () => { + const cleanup = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect) + + const Probe = Component.makeUntraced("DebouncedCleanupProbe")(function*(props: { readonly value: string }) { + const result = yield* Component.useOnChange( + () => Effect.gen(function*() { + yield* Effect.addFinalizer(() => Effect.sync(() => cleanup(props.value))) + return props.value + }), + [props.value], + { finalizerExecutionDebounce: "20 millis" }, + ) + + return
{result}
+ }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + + view.rerender( + + + + ) + + await screen.findByText("second") + expect(cleanup).not.toHaveBeenCalled() + + await new Promise(resolve => setTimeout(resolve, 5)) + expect(cleanup).not.toHaveBeenCalled() + + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"), { timeout: 100 }) + + view.unmount() + await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"), { timeout: 100 }) + await Effect.runPromise(runtime.runtime.disposeEffect) + }) + + it("does not remount a component when only nonReactiveTags change", async () => { + const mounts = vi.fn() + const unmounts = vi.fn() + const runtime = ReactRuntime.make(Layer.empty) + const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect) + + const SubComponent = Component.makeUntraced("NonReactiveSubComponent")(function*() { + const service = yield* ValueService + + yield* Component.useOnMount(() => Effect.gen(function*() { + yield* Effect.sync(() => mounts()) + yield* Effect.addFinalizer(() => Effect.sync(() => unmounts())) + })) + + return
{service.value}
+ }).pipe( + Component.withOptions({ nonReactiveTags: [ValueService] }) + ) + + const Parent = Component.makeUntraced("NonReactiveParent")(function*(props: { readonly value: string }) { + const serviceLayer = React.useMemo( + () => Layer.succeed(ValueService, { value: props.value }), + [props.value], + ) + const context = yield* Component.useContextFromLayer(serviceLayer, { + finalizerExecutionDebounce: 0, + }) + const Child = yield* Effect.provide(SubComponent.use, context) + + return + }).pipe( + Component.withRuntime(runtime.context) + ) + + const view = render( + + + + ) + + await screen.findByText("first") + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + + view.rerender( + + + + ) + + await screen.findByText("second") + expect(mounts).toHaveBeenCalledTimes(1) + expect(unmounts).not.toHaveBeenCalled() + + view.unmount() + await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1)) + await Effect.runPromise(runtime.runtime.disposeEffect) + }) })