diff --git a/packages/effect-fc-next/src/Component.ts b/packages/effect-fc-next/src/Component.ts index c9a45be..0147258 100644 --- a/packages/effect-fc-next/src/Component.ts +++ b/packages/effect-fc-next/src/Component.ts @@ -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", } diff --git a/packages/effect-fc-next/test/Component.test.tsx b/packages/effect-fc-next/test/Component.test.tsx index 34393f0..615953e 100644 --- a/packages/effect-fc-next/test/Component.test.tsx +++ b/packages/effect-fc-next/test/Component.test.tsx @@ -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( @@ -40,7 +40,7 @@ describe("Component", () => { ) 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( @@ -77,7 +77,7 @@ describe("Component", () => { ) expect(await screen.findByText("value:1")).toBeTruthy() - expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledTimes(2) view.rerender( @@ -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( @@ -229,7 +231,7 @@ describe("Component", () => { ) await screen.findByText("a:1") - expect(seenCallbacks).toHaveLength(2) + expect(seenCallbacks).toHaveLength(initialLength) view.rerender( @@ -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
{service.value}
+ return }).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", () => {
) - 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( ) - 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(() => {}) + + const Probe = Component.makeUntraced("DiscardedSuspenseProbe")(function*() { + yield* Component.useReactEffect(() => Effect.sync(setup), []) + React.use(pending) + return
committed
+ }).pipe( + Component.withContext(runtime.context) + ) + + let view!: ReturnType + await act(async () => { + view = render( + fallback}> + + + + + ) + }) + + 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(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
committed
+ }).pipe( + Component.withContext(runtime.context) + ) + + let view!: ReturnType + await act(async () => { + view = render( + fallback}> + + + + + ) + }) + + 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() + } + }) }) diff --git a/packages/effect-fc-next/test/setup.ts b/packages/effect-fc-next/test/setup.ts new file mode 100644 index 0000000..839583d --- /dev/null +++ b/packages/effect-fc-next/test/setup.ts @@ -0,0 +1,4 @@ +import { configure } from "@testing-library/react" + + +configure({ reactStrictMode: true }) diff --git a/packages/effect-fc-next/vitest.config.ts b/packages/effect-fc-next/vitest.config.ts index cec0e39..1374d42 100644 --- a/packages/effect-fc-next/vitest.config.ts +++ b/packages/effect-fc-next/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ test: { environment: "jsdom", include: ["test/**/*.test.ts?(x)"], + setupFiles: ["test/setup.ts"], }, })