Change build system
Lint / lint (push) Failing after 40s

This commit is contained in:
Julien Valverdé
2026-07-25 23:47:16 +02:00
parent 3533fa0c84
commit 65e90325d0
26 changed files with 78 additions and 48 deletions
@@ -0,0 +1,63 @@
import { act, fireEvent, render, screen } from "@testing-library/react"
import { Effect, Layer } from "effect"
import * as React from "react"
import { describe, expect, it, vi } from "vitest"
import * as Async from "./Async.js"
import * as Component from "./Component.js"
import * as Memoized from "./Memoized.js"
import * as ReactRuntime from "./ReactRuntime.js"
describe("Async", () => {
it("does not rerun for an unrelated parent state update", async () => {
const load = vi.fn((_id: number) => Effect.never)
const renderPost = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const context = await runtime.runtime.context()
const Post = Component.make("Post")(function*(props: { readonly id: number }) {
renderPost()
const value = yield* Component.useOnChange(() => load(props.id), [props.id])
return <div>{value}</div>
}).pipe(
Async.async,
Memoized.memoized,
)
const Parent = Component.make("Parent")(function*() {
const [text, setText] = React.useState("")
const AsyncPost = yield* Post.use
return <>
<input
aria-label="text"
value={text}
onChange={event => setText(event.currentTarget.value)}
/>
<AsyncPost id={1} fallback={<div>loading</div>} />
</>
}).pipe(Component.withContext(runtime.context))
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<runtime.context.Provider value={context}>
<Parent />
</runtime.context.Provider>,
)
})
expect(screen.getByText("loading")).toBeTruthy()
expect(load).toHaveBeenCalledTimes(2)
const callsAfterLoad = load.mock.calls.length
const rendersAfterLoad = renderPost.mock.calls.length
await act(async () => {
fireEvent.change(screen.getByLabelText("text"), { target: { value: "a" } })
})
expect(load).toHaveBeenCalledTimes(callsAfterLoad)
expect(renderPost).toHaveBeenCalledTimes(rendersAfterLoad)
view.unmount()
await runtime.runtime.dispose()
})
})
@@ -0,0 +1,571 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { Context, Effect, HashMap, Layer, SubscriptionRef } from "effect"
import * as React from "react"
import { afterEach, describe, expect, it, vi } from "vitest"
import * as Component from "./Component.js"
import * as ReactRuntime from "./ReactRuntime.js"
import * as Refreshable from "./Refreshable.js"
import * as ScopeRegistry from "./ScopeRegistry.js"
class ValueService extends Context.Service<ValueService, { readonly value: string }>()("ValueService") {}
afterEach(() => {
vi.useRealTimers()
})
describe("Component", () => {
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()
const Probe = Component.makeUntraced("UseOnMountProbe")(function*() {
const value = yield* Component.useOnMount(onMount)
return <div>{value}</div>
}).pipe(
Component.withContext(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("mounted")
expect(onMount).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
expect(await screen.findByText("mounted")).toBeTruthy()
expect(onMount).toHaveBeenCalledTimes(2)
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.withContext(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
await screen.findByText("value:1")
expect(onChange).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={1} />
</runtime.context.Provider>
)
expect(await screen.findByText("value:1")).toBeTruthy()
expect(onChange).toHaveBeenCalledTimes(2)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe value={2} />
</runtime.context.Provider>
)
await screen.findByText("value:2")
expect(onChange).toHaveBeenCalledTimes(4)
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.withContext(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.withContext(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: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.withContext(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="a" />
</runtime.context.Provider>
)
await screen.findByText("a:1")
const initialLength = seenCallbacks.length
const initialCallback = seenCallbacks.at(-1)
expect(initialCallback?.(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(initialLength)
view.rerender(
<runtime.context.Provider value={effectRuntime}>
<Probe prefix="b" />
</runtime.context.Provider>
)
await screen.findByText("b:1")
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()
})
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.withContext(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
const [count, setCount] = React.useState(0)
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(() => mounts())
yield* Effect.addFinalizer(() => Effect.sync(() => unmounts()))
}))
return <button onClick={() => setCount(value => value + 1)}>{`${service.value}:${count}`}</button>
}).pipe(
Component.withOptions({
nonReactiveTags: [...Component.defaultOptions.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.useLayer(serviceLayer, {
finalizerExecutionDebounce: 0,
})
const Child = yield* Effect.provide(SubComponent.use, context)
return <Child />
}).pipe(
Component.withContext(runtime.context)
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Parent value="first" />
</runtime.context.Provider>
)
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:1")
expect(mounts).toHaveBeenCalledTimes(initialMounts)
expect(unmounts).not.toHaveBeenCalled()
view.unmount()
await waitFor(() => expect(unmounts).toHaveBeenCalledTimes(1))
await runtime.runtime.dispose()
})
it("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 scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
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.withOptions({ scopeCommitTimeout: "10 millis" }),
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()
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
}
finally {
view.unmount()
await runtime.runtime.dispose()
}
})
it("keeps committed scopes alive without heartbeats", async () => {
const acquisitions = vi.fn()
const releases = vi.fn()
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
const Probe = Component.makeUntraced("CommittedScopeProbe")(function*() {
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.sync(acquisitions)
yield* Effect.addFinalizer(() => Effect.sync(releases))
}))
return <div>committed</div>
}).pipe(
Component.withOptions({
finalizerExecutionDebounce: "10 millis",
scopeCommitTimeout: "10 millis",
}),
Component.withContext(runtime.context),
)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
await screen.findByText("committed")
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1))
await new Promise(resolve => setTimeout(resolve, 30))
expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(1)
expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length - 1)
view.unmount()
await waitFor(() => expect(releases).toHaveBeenCalledTimes(acquisitions.mock.calls.length))
await waitFor(() => expect(HashMap.size(Effect.runSync(SubscriptionRef.get(scopeRegistry.ref)))).toBe(0))
await runtime.runtime.dispose()
})
it("does not expire a scope while a descendant is suspended past the finalizer debounce", async () => {
const runtime = ReactRuntime.make(Layer.empty)
let resolve!: () => void
const pending = new Promise<void>(complete => {
resolve = complete
})
const Suspended = () => {
React.use(pending)
return <div>committed</div>
}
let view!: ReturnType<typeof render>
await act(async () => {
view = render(
<ReactRuntime.Provider runtime={runtime} fallback={<div>fallback</div>}>
<Suspended />
</ReactRuntime.Provider>
)
})
try {
await screen.findByText("fallback")
await new Promise(resolve => setTimeout(resolve, 150))
await act(async () => {
resolve()
await pending
})
await screen.findByText("committed")
}
finally {
view.unmount()
await runtime.runtime.dispose()
}
})
it("clears ScopeRegistry 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 scopeRegistry = Context.get(effectRuntime, ScopeRegistry.ScopeRegistry)
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.withOptions({ scopeCommitTimeout: "10 millis" }),
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(SubscriptionRef.get(scopeRegistry.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()
}
})
it("refreshes a registered body while preserving or resetting local state", async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
const makeProbe = (label: string) => Component.makeUntraced("RefreshProbe")(function*() {
const [count, setCount] = React.useState(0)
return <button type="button" onClick={() => setCount(value => value + 1)}>{label}:{count}</button>
})
const original = makeProbe("old")
const cell = Refreshable.makeCell(original, "hooks", false)
Refreshable.attach(original, cell)
const Probe = Component.withContext(original, runtime.context)
const view = render(
<runtime.context.Provider value={effectRuntime}>
<Probe />
</runtime.context.Provider>
)
fireEvent.click(await screen.findByRole("button"))
expect(screen.getByRole("button").textContent).toBe("old:1")
await act(async () => {
cell.update(makeProbe("compatible"), "hooks", false)
await Promise.resolve()
})
expect(screen.getByRole("button").textContent).toBe("compatible:1")
await act(async () => {
cell.update(makeProbe("reset"), "changed-hooks", false)
await Promise.resolve()
})
expect(screen.getByRole("button").textContent).toBe("reset:0")
view.unmount()
await runtime.runtime.dispose()
})
})
+165
View File
@@ -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 "./Component.js"
import * as Lens from "./Lens.js"
import * as ReactRuntime from "./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.withContext(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.withContext(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.withContext(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.withContext(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()
})
})
+167
View File
@@ -0,0 +1,167 @@
import { Effect, type Scope, Stream } from "effect"
import { AsyncResult } from "effect/unstable/reactivity"
import { describe, expect, it } from "vitest"
import * as Query from "./Query.js"
import * as QueryClient from "./QueryClient.js"
import * as View from "./View.js"
const runQueryTest = <A, E>(effect: Effect.Effect<A, E, QueryClient.QueryClient | Scope.Scope>) =>
Effect.runPromise(Effect.scoped(effect.pipe(
Effect.provide(QueryClient.layer()),
)))
const staticKey = <K>(key: K): View.View<K> => View.make({
get: Effect.succeed(key),
changes: Stream.make(key),
})
const expectSuccessValue = <A, E>(
state: Query.FinalQueryState<unknown, A, E>,
): A => {
expect(AsyncResult.isSuccess(state.result)).toBe(true)
if (!AsyncResult.isSuccess(state.result))
throw new Error(`Expected Success result, received ${state.result._tag}`)
return state.result.value
}
describe("Query", () => {
it("fetch caches successful results until they are invalidated or stale", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
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(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 = staticKey<readonly [number]>([1])
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 = staticKey<readonly [number]>([1])
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 = staticKey<readonly [number]>([1])
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 view automatically and records its latest final state", async () => {
let calls = 0
const key = staticKey<readonly [number]>([1])
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",
})
const latestFinalState = yield* Effect.sleep("1 millis").pipe(
Effect.andThen(View.get(query.latestFinalState)),
Effect.flatMap(Effect.fromOption),
Effect.eventually,
Effect.timeout("1 second"),
)
return {
state: yield* View.get(query.state),
latestFinalState,
}
})
const result = await runQueryTest(effect)
expect(calls).toBe(1)
expect(result.state.key).toEqual([1])
expect(expectSuccessValue(result.latestFinalState)).toBe("value:1:1")
})
})
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest"
import type * as Component from "./Component.js"
import * as Refreshable from "./Refreshable.js"
describe("Refreshable", () => {
it("attaches a cell and notifies subscribers after a compatible update", async () => {
const first = {} as Component.Component.Any
const cell = Refreshable.makeCell(first, "hooks", false)
const listener = vi.fn()
expect(Refreshable.isRefreshable(first)).toBe(false)
const attached = Refreshable.attach(first, cell)
expect(attached).toBe(first)
expect(Refreshable.isRefreshable(first)).toBe(true)
expect(attached[Refreshable.RefreshableTypeId]).toBe(cell)
expect(Object.getPrototypeOf(attached).asFunctionComponent)
.toBe(Refreshable.RefreshablePrototype.asFunctionComponent)
cell.subscribe(listener)
const second = {} as Component.Component.Any
cell.update(second, "hooks", false)
expect(cell.current).toBe(second)
expect(cell.snapshot).toEqual({
revision: 1,
resetRevision: 0,
})
await Promise.resolve()
expect(listener).toHaveBeenCalledTimes(1)
})
it("requests a remount when a signature changes or reset is forced", () => {
const component = {} as Component.Component.Any
const cell = Refreshable.makeCell(component, "one", false)
cell.update({} as Component.Component.Any, "two", false)
expect(cell.snapshot.resetRevision).toBe(1)
cell.update({} as Component.Component.Any, "two", true)
expect(cell.snapshot.resetRevision).toBe(2)
})
it("builds the refresh shell from the current descriptor implementation", () => {
const implementation = () => null
const makeFunctionComponent = vi.fn(() => implementation)
const component = {
makeFunctionComponent,
} as unknown as Component.ComponentImpl.Any
const contextRef = {
current: {},
} as never
const cell = Refreshable.makeCell(component, "hooks", false)
const attached = Refreshable.attach(component, cell)
expect(attached.asFunctionComponent(contextRef)).not.toBe(implementation)
expect(makeFunctionComponent).toHaveBeenCalledWith(contextRef)
})
})
+94
View File
@@ -0,0 +1,94 @@
import { render, screen, waitFor } from "@testing-library/react"
import { Effect, Layer, SubscriptionRef } from "effect"
import { Lens } from "effect-lens"
import { describe, expect, it } from "vitest"
import * as Component from "./Component.js"
import * as ReactRuntime from "./ReactRuntime.js"
import * as View from "./View.js"
const makeRuntime = async () => {
const runtime = ReactRuntime.make(Layer.empty)
const effectRuntime = await runtime.runtime.context()
return {
runtime,
effectRuntime,
dispose: () => runtime.runtime.dispose(),
}
}
describe("View", () => {
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("ViewUseAllProbe")(function*() {
const [currentCount, currentLabel] = yield* View.useAll([count, label])
return <div>{`${currentCount}:${currentLabel}`}</div>
}).pipe(
Component.withContext(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("ViewUseAllEquivalenceProbe")(function*() {
const [currentItem, currentFlag] = yield* View.useAll([item, flag], {
equivalence: ([selfItem, selfFlag], [thatItem, thatFlag]) =>
selfItem.id === thatItem.id && selfFlag === thatFlag,
})
return <div>{`${currentItem.label}:${currentFlag ? "on" : "off"}`}</div>
}).pipe(
Component.withContext(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()
})
})
@@ -0,0 +1,4 @@
import { configure } from "@testing-library/react"
configure({ reactStrictMode: true })