@@ -0,0 +1,354 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
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.Service<ValueService, { readonly value: string }>()("ValueService") {}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe("Component", () => {
|
||||
it("runs useOnMount only once across rerenders", async () => {
|
||||
const onMount = vi.fn(() => Effect.succeed("mounted"))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
|
||||
const value = yield* Component.useOnMount(onMount)
|
||||
return <div>{value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("mounted")
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
expect(await screen.findByText("mounted")).toBeTruthy()
|
||||
expect(onMount).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("recomputes useOnChange only when dependencies change", async () => {
|
||||
const onChange = vi.fn((value: number) => Effect.succeed(`value:${value}`))
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("UseOnChangeProbe")(function*(props: { readonly value: number }) {
|
||||
const result = yield* Component.useOnChange(() => onChange(props.value), [props.value], {
|
||||
finalizerExecutionDebounce: 0,
|
||||
})
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:1")
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={1} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
expect(await screen.findByText("value:1")).toBeTruthy()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value={2} />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("value:2")
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
|
||||
view.unmount()
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("closes the previous scope on dependency changes and unmount", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
const Probe = Component.makeUntraced("ScopeCleanupProbe")(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: 0 },
|
||||
)
|
||||
|
||||
return <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(cleanup).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("first"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(1)
|
||||
|
||||
view.unmount()
|
||||
|
||||
await waitFor(() => expect(cleanup).toHaveBeenCalledWith("second"))
|
||||
expect(cleanup).toHaveBeenCalledTimes(2)
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
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 runtime.runtime.context()
|
||||
|
||||
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 <div>{props.value}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
await waitFor(() => expect(lifecycle).toHaveBeenCalledWith("mount:first"))
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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 runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("keeps useCallbackSync stable until dependencies change", async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
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 <div>{callback(1)}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
expect(seenCallbacks[0]).toBe(seenCallbacks[1])
|
||||
expect(seenCallbacks[0]?.(2)).toBe("a:2")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="a" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("a:1")
|
||||
expect(seenCallbacks).toHaveLength(2)
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe prefix="b" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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 runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
it("delays cleanup according to finalizerExecutionDebounce", async () => {
|
||||
const cleanup = vi.fn()
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
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 <div>{result}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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 runtime.runtime.dispose()
|
||||
})
|
||||
|
||||
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 runtime.runtime.context()
|
||||
|
||||
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 <div>{service.value}</div>
|
||||
}).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 <Child />
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="first" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("first")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Parent value="second" />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
await screen.findByText("second")
|
||||
expect(mounts).toHaveBeenCalledTimes(1)
|
||||
expect(unmounts).not.toHaveBeenCalled()
|
||||
|
||||
view.unmount()
|
||||
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
|
||||
await runtime.runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Layer, SubscriptionRef } from "effect"
|
||||
import * as React from "react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "../src/Component.js"
|
||||
import * as Lens from "../src/Lens.js"
|
||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => runtime.runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
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* Lens.useState(lens)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{value}</div>
|
||||
<button onClick={() => setValue(previous => previous + 1)}>increment</button>
|
||||
</>
|
||||
)
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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* Lens.useState(lens, {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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<string, never, never, never, never> | undefined
|
||||
|
||||
const Probe = Component.makeUntraced("LensUseFromReactStateProbe")(function*() {
|
||||
const [value, setValue] = React.useState("hello")
|
||||
const reactLens = yield* Lens.useFromReactState([value, setValue])
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <button onClick={() => setValue(previous => `${previous}!`)}>{value}</button>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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* Lens.useFromReactState([value, setValue], {
|
||||
equivalence: (self, that) => self.id === that.id,
|
||||
})
|
||||
|
||||
yield* Component.useOnMount(() => Effect.sync(() => {
|
||||
lens = reactLens
|
||||
}))
|
||||
|
||||
return <div>{value.label}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Effect, Option, type Scope, Stream } from "effect"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Query from "../src/Query.js"
|
||||
import * as QueryClient from "../src/QueryClient.js"
|
||||
import * as Result from "../src/Result.js"
|
||||
|
||||
|
||||
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
|
||||
Effect.runPromise(Effect.scoped(effect.pipe(
|
||||
Effect.provide(QueryClient.QueryClient.Default),
|
||||
)))
|
||||
|
||||
const expectSuccessValue = <A, E, P>(
|
||||
result: Result.Result<A, E, P>,
|
||||
): A => {
|
||||
expect(Result.isSuccess(result)).toBe(true)
|
||||
|
||||
if (!Result.isSuccess(result))
|
||||
throw new Error(`Expected Success result, received ${result._tag}`)
|
||||
|
||||
return result.value
|
||||
}
|
||||
|
||||
const expectSomeValue = <A>(option: Option.Option<A>): A => {
|
||||
expect(Option.isSome(option)).toBe(true)
|
||||
|
||||
if (!Option.isSome(option))
|
||||
throw new Error("Expected Some option, received None")
|
||||
|
||||
return option.value
|
||||
}
|
||||
|
||||
describe("Query", () => {
|
||||
it("fetch caches successful results until they are invalidated or stale", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(result[0]._tag).toBe("Success")
|
||||
expect(result[1]._tag).toBe("Success")
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:1")
|
||||
})
|
||||
|
||||
it("refresh reruns the latest query key", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "0 millis",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* Effect.sleep("1 millis")
|
||||
const refreshed = yield* query.refresh
|
||||
|
||||
return [first, refreshed] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCacheEntry forces the next fetch for that key to rerun", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCacheEntry([1])
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("invalidateCache clears cached entries for the query function", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.empty as Stream.Stream<readonly [number]>
|
||||
|
||||
const result = await runQueryTest(Effect.gen(function*() {
|
||||
const query = yield* Query.make({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
const first = yield* query.fetch([1])
|
||||
yield* query.invalidateCache
|
||||
const second = yield* query.fetch([1])
|
||||
|
||||
return [first, second] as const
|
||||
}))
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(expectSuccessValue(result[0])).toBe("value:1:1")
|
||||
expect(expectSuccessValue(result[1])).toBe("value:1:2")
|
||||
})
|
||||
|
||||
it("service starts the key stream automatically and updates latest state", async () => {
|
||||
let calls = 0
|
||||
const key = Stream.make([1] as const) as Stream.Stream<readonly [number]>
|
||||
|
||||
const effect = Effect.gen(function*() {
|
||||
const query = yield* Query.service({
|
||||
key,
|
||||
f: ([id]: readonly [number]) => Effect.sync(() => {
|
||||
calls += 1
|
||||
return `value:${id}:${calls}`
|
||||
}),
|
||||
staleTime: "1 minute",
|
||||
})
|
||||
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
return {
|
||||
final: yield* query.result.get,
|
||||
latestKey: yield* query.latestKey.get,
|
||||
latestFinalResult: yield* query.latestFinalResult.get,
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runQueryTest(effect)
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(expectSuccessValue(result.final)).toBe("value:1:1")
|
||||
expect(expectSomeValue(result.latestKey)).toEqual([1])
|
||||
expect(expectSuccessValue(expectSomeValue(result.latestFinalResult))).toBe("value:1:1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { Effect, Fiber, Layer, Stream, SubscriptionRef } from "effect"
|
||||
import { Lens } from "effect-lens"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import * as Component from "../src/Component.js"
|
||||
import * as ReactRuntime from "../src/ReactRuntime.js"
|
||||
import * as Subscribable from "../src/Subscribable.js"
|
||||
|
||||
|
||||
const makeRuntime = async () => {
|
||||
const runtime = ReactRuntime.make(Layer.empty)
|
||||
const effectRuntime = await runtime.runtime.context()
|
||||
|
||||
return {
|
||||
runtime,
|
||||
effectRuntime,
|
||||
dispose: () => runtime.runtime.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
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 = Subscribable.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 = Subscribable.zipLatestAll(left, right)
|
||||
const values: Array<readonly [number, string]> = []
|
||||
|
||||
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"]))
|
||||
|
||||
await Effect.runPromise(Fiber.interrupt(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* Subscribable.useAll([count, label])
|
||||
|
||||
return <div>{`${currentCount}:${currentLabel}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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* Subscribable.useAll([item, flag], {
|
||||
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
|
||||
selfItem.id === thatItem.id && selfFlag === thatFlag,
|
||||
})
|
||||
|
||||
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
|
||||
}).pipe(
|
||||
Component.withRuntime(runtime.context)
|
||||
)
|
||||
|
||||
const view = render(
|
||||
<runtime.context.Provider value={effectRuntime}>
|
||||
<Probe />
|
||||
</runtime.context.Provider>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user