diff --git a/packages/effect-fc/test/Lens.test.tsx b/packages/effect-fc/test/Lens.test.tsx
new file mode 100644
index 0000000..4728bfc
--- /dev/null
+++ b/packages/effect-fc/test/Lens.test.tsx
@@ -0,0 +1,166 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { Effect, Layer, SubscriptionRef } from "effect"
+import { Lens } from "effect-lens"
+import * as React from "react"
+import { describe, expect, it } from "vitest"
+import * as Component from "../src/Component.js"
+import * as LensModule from "../src/Lens.js"
+import * as ReactRuntime from "../src/ReactRuntime.js"
+
+
+const makeRuntime = async () => {
+ const runtime = ReactRuntime.make(Layer.empty)
+ const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
+
+ return {
+ runtime,
+ effectRuntime,
+ dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
+ }
+}
+
+describe("Lens", () => {
+ it("useState stays in sync with lens updates in both directions", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ const ref = await Effect.runPromise(SubscriptionRef.make(0))
+ const lens = Lens.fromSubscriptionRef(ref)
+
+ const Probe = Component.makeUntraced("LensUseStateProbe")(function*() {
+ const [value, setValue] = yield* LensModule.useState(lens)
+
+ return (
+ <>
+
{value}
+
+ >
+ )
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("0")
+
+ await Effect.runPromise(Lens.set(lens, 5))
+ await screen.findByText("5")
+
+ fireEvent.click(screen.getByRole("button", { name: "increment" }))
+ await screen.findByText("6")
+ expect(await Effect.runPromise(Lens.get(lens))).toBe(6)
+
+ view.unmount()
+ await dispose()
+ })
+
+ it("useState respects the provided equivalence when subscribing to lens changes", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ const ref = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
+ const lens = Lens.fromSubscriptionRef(ref)
+
+ const Probe = Component.makeUntraced("LensUseStateEquivalenceProbe")(function*() {
+ const [value] = yield* LensModule.useState(lens, {
+ equivalence: (self, that) => self.id === that.id,
+ })
+
+ return {value.label}
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("first")
+
+ await Effect.runPromise(Lens.set(lens, { id: 1, label: "ignored" }))
+ await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
+ expect(screen.queryByText("ignored")).toBeNull()
+
+ await Effect.runPromise(Lens.set(lens, { id: 2, label: "updated" }))
+ await screen.findByText("updated")
+
+ view.unmount()
+ await dispose()
+ })
+
+ it("useFromReactState writes React state changes into the returned lens", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ let lens: Lens.Lens | undefined
+
+ const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
+ const [value, setValue] = React.useState("hello")
+ const reactLens = yield* LensModule.useFromReactState([value, setValue])
+
+ yield* Component.useOnMount(() => Effect.sync(() => {
+ lens = reactLens
+ }))
+
+ return
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("hello")
+ await waitFor(() => expect(lens).toBeDefined())
+
+ fireEvent.click(screen.getByRole("button", { name: "hello" }))
+ await screen.findByText("hello!")
+ await waitFor(async () => expect(await Effect.runPromise(Lens.get(lens!))).toBe("hello!"))
+
+ view.unmount()
+ await dispose()
+ })
+
+ it("useFromReactState respects equivalence when lens updates flow back into React state", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ let lens: Lens.Lens<{ readonly id: number; readonly label: string }, never, never, never, never> | undefined
+
+ const Probe = Component.makeUntraced("LensUseFromReactStateEquivalenceProbe")(function*() {
+ const [value, setValue] = React.useState({ id: 1, label: "first" })
+ const reactLens = yield* LensModule.useFromReactState([value, setValue], {
+ equivalence: (self, that) => self.id === that.id,
+ })
+
+ yield* Component.useOnMount(() => Effect.sync(() => {
+ lens = reactLens
+ }))
+
+ return {value.label}
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("first")
+ await waitFor(() => expect(lens).toBeDefined())
+
+ await Effect.runPromise(Lens.set(lens!, { id: 1, label: "ignored" }))
+ await waitFor(() => expect(screen.getByText("first")).toBeTruthy())
+ expect(screen.queryByText("ignored")).toBeNull()
+
+ await Effect.runPromise(Lens.set(lens!, { id: 2, label: "updated" }))
+ await screen.findByText("updated")
+
+ view.unmount()
+ await dispose()
+ })
+})
diff --git a/packages/effect-fc/test/Subscribable.test.tsx b/packages/effect-fc/test/Subscribable.test.tsx
new file mode 100644
index 0000000..eec6289
--- /dev/null
+++ b/packages/effect-fc/test/Subscribable.test.tsx
@@ -0,0 +1,130 @@
+import { render, screen, waitFor } from "@testing-library/react"
+import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
+import { Lens } from "effect-lens"
+import * as React from "react"
+import { describe, expect, it } from "vitest"
+import * as Component from "../src/Component.js"
+import * as ReactRuntime from "../src/ReactRuntime.js"
+import * as SubscribableModule from "../src/Subscribable.js"
+
+
+const makeRuntime = async () => {
+ const runtime = ReactRuntime.make(Layer.empty)
+ const effectRuntime = await Effect.runPromise(runtime.runtime.runtimeEffect)
+
+ return {
+ runtime,
+ effectRuntime,
+ dispose: () => Effect.runPromise(runtime.runtime.disposeEffect),
+ }
+}
+
+describe("Subscribable", () => {
+ it("zipLatestAll reads current values from all inputs", async () => {
+ const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
+ const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
+ const left = Lens.fromSubscriptionRef(leftRef)
+ const right = Lens.fromSubscriptionRef(rightRef)
+
+ const zipped = SubscribableModule.zipLatestAll(left, right)
+
+ expect(await Effect.runPromise(zipped.get)).toEqual([1, "a"])
+ })
+
+ it("zipLatestAll emits updates when any input changes", async () => {
+ const leftRef = await Effect.runPromise(SubscriptionRef.make(1))
+ const rightRef = await Effect.runPromise(SubscriptionRef.make("a"))
+ const left = Lens.fromSubscriptionRef(leftRef)
+ const right = Lens.fromSubscriptionRef(rightRef)
+
+ const zipped = SubscribableModule.zipLatestAll(left, right)
+ const values: Array = []
+
+ const collector = Effect.runFork(Effect.scoped(zipped.changes.pipe(
+ Stream.runForEach(value => Effect.sync(() => {
+ values.push(value as readonly [number, string])
+ })),
+ )))
+
+ await Effect.runPromise(Lens.set(left, 2))
+ await waitFor(() => expect(values).toContainEqual([2, "a"]))
+
+ await Effect.runPromise(Lens.set(right, "b"))
+ await waitFor(() => expect(values).toContainEqual([2, "b"]))
+
+ Fiber.interruptFork(collector)
+ })
+
+ it("useAll returns the latest values and rerenders when any input changes", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ const countRef = await Effect.runPromise(SubscriptionRef.make(1))
+ const labelRef = await Effect.runPromise(SubscriptionRef.make("a"))
+ const count = Lens.fromSubscriptionRef(countRef)
+ const label = Lens.fromSubscriptionRef(labelRef)
+
+ const Probe = Component.makeUntraced("SubscribableUseAllProbe")(function*() {
+ const [currentCount, currentLabel] = yield* SubscribableModule.useAll([count, label])
+
+ return {`${currentCount}:${currentLabel}`}
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("1:a")
+
+ await Effect.runPromise(Lens.set(count, 2))
+ await screen.findByText("2:a")
+
+ await Effect.runPromise(Lens.set(label, "b"))
+ await screen.findByText("2:b")
+
+ view.unmount()
+ await dispose()
+ })
+
+ it("useAll respects the provided equivalence when processing updates", async () => {
+ const { runtime, effectRuntime, dispose } = await makeRuntime()
+ const itemRef = await Effect.runPromise(SubscriptionRef.make({ id: 1, label: "first" }))
+ const flagRef = await Effect.runPromise(SubscriptionRef.make(true))
+ const item = Lens.fromSubscriptionRef(itemRef)
+ const flag = Lens.fromSubscriptionRef(flagRef)
+
+ const Probe = Component.makeUntraced("SubscribableUseAllEquivalenceProbe")(function*() {
+ const [currentItem, currentFlag] = yield* SubscribableModule.useAll([item, flag], {
+ equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
+ selfItem.id === thatItem.id && selfFlag === thatFlag,
+ })
+
+ return {`${currentItem.label}:${currentFlag ? "on" : "off"}`}
+ }).pipe(
+ Component.withRuntime(runtime.context)
+ )
+
+ const view = render(
+
+
+
+ )
+
+ await screen.findByText("first:on")
+
+ await Effect.runPromise(Lens.set(item, { id: 1, label: "ignored" }))
+ await waitFor(() => expect(screen.getByText("first:on")).toBeTruthy())
+ expect(screen.queryByText("ignored:on")).toBeNull()
+
+ await Effect.runPromise(Lens.set(flag, false))
+ await screen.findByText("ignored:off")
+
+ await Effect.runPromise(Lens.set(item, { id: 2, label: "updated" }))
+ await screen.findByText("updated:off")
+
+ view.unmount()
+ await dispose()
+ })
+})